Rate limit all endpoints (#1213)
* Demonstration of bug to test when middleware added - Adding middleware, even no-op, causes test to fail * Make middleware async, but introduce delay. Revert test back to original. * Revert tests * Add a 1ms sleep to the test to reduce likelihood of flakiness * Rate limiting on all express endpoints using middleware * Adds all configuration for existing rate limited endpoints * It is helpful to add the package to yarn first * Implements respectsLimits using Redis rate limiter * Fix for test `Should rate-limit user creation` - if rate limit error, post to `/auth/local/register` will return a 429 status code * All rate limiting provided by new ratelimiter.ts * Consolidate typescript interfaces * Amend signature of function to require source to be passed in, and not try to guess it from the request * Rename respectsLimits to isWithinRateLimits * Throw within catch of Promise * Replace rejectsRequestWithRatelimitStatusIfNeeded throughout code * Sending rate limit response should deal with other types of error - Sentry notified of the error * Express middleware rate limits by a 3 second burst or a daily rate - Provide action when generating 429 response * Prevent DOS of Redis * Add 'Retry-After' for all cases when responding with 429 status code - default of 1 day, but dynamic based on available information * Generate rate limiters once, on init - Improved and consistent handling of exit from functions - fixed environment variable names * WIP Refactor rate limiting setup Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com> * WIP: fixed references, now runs but tests fail * Use getSourceFromRequest where possible * WIP: unit tests for rate limiter * Unit tests for ratelimiter * feat(IFC): WIP IFC parser improvements * Revert "feat(IFC): WIP IFC parser improvements" This reverts commit093089a2c4. * refactor authz, rate limiting middleware to global Co-authored-by: Kristaps Fabians Geikins <fabis94@users.noreply.github.com> Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com> * invites tests fix * fix(server ratelimiter): export public interfaces * Unit test for rate limiter use in memory rate limiter - in memory rate limiter is configured with zero limit by default * Fixed #1219 (#1221) * WIP: improve auth test for rate limiting user creation * ci(circleci config): publishing was broken when main branch was tagged (i.e. for releases) (#1224) * Gitignore CPU profiles * All tests are now passing locally * Fixed an issue in the frontend which was causing the views not to work. Fixed an issue with object selection camera animation where the dolly lerp factor was much too high for smooth animation (#1225) * feat(structured logging): implements structured logging for backend (#1217) * each log line is a json object * structured logging allows logs to be ingested by machines and the logs to be indexed and queried addresses #1105 * structured logging allows arbitrary properties to be appended to each log line, and ingestion of logs to remain robust * Structured logging provided by `pino` library * Add `express-pino-logger` dependency * Remove `debug`, `morgan`, and `morgan-debug` and replace with structured logging * `console.log` & `console.error` replaced with structured logging in backend * Remove `DEBUG` environment variable and replace with `LOG_LEVEL` - Note that there is a test which reads from a logged line on `stdout`. This is not robust, it would be better to use the childProcess.pid to look up the port number. * Log errors at points we explicitly send error to Sentry * Amend indentation of a couple of log messages to align indentation with others * Revert "feat(structured logging): implements structured logging for backend (#1217)" (#1227) This reverts commit84cb74e8b3. * Move error to core/errors - augmented typescript types moved to type-augmentations * Added a missing wait in the screenshot generation loop (#1228) * refactor(server rest api): remove duplicate rate limit requests * feat(server rate limits): increase rate limits for the upload endpoints * chore(server rate limits): final cleanup Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com> Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com> Co-authored-by: Dimitrie Stefanescu <didimitrie@gmail.com> Co-authored-by: Kristaps Fabians Geikins <fabis94@users.noreply.github.com> Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com> Co-authored-by: Alexandru Popovici <alexandrupopoviciioan@gmail.com>
This commit is contained in:
@@ -34,6 +34,7 @@ packages/server/reports*
|
||||
|
||||
# Profiler output
|
||||
events.json
|
||||
*.cpuprofile
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
+38
-32
@@ -25,15 +25,16 @@ import {
|
||||
import { SubscriptionServer } from 'subscriptions-transport-ws'
|
||||
import { execute, subscribe } from 'graphql'
|
||||
|
||||
import { buildContext } from '@/modules/shared'
|
||||
import knex from '@/db/knex'
|
||||
import { monitorActiveConnections } from '@/logging/httpServerMonitoring'
|
||||
import { buildErrorFormatter } from '@/modules/core/graph/setup'
|
||||
import { isDevEnv, isTestEnv } from '@/modules/shared/helpers/envHelper'
|
||||
import * as ModulesSetup from '@/modules'
|
||||
import { Optional } from '@/modules/shared/helpers/typeHelper'
|
||||
import { createRateLimiterMiddleware } from '@/modules/core/services/ratelimiter'
|
||||
|
||||
import { get, has, isString, toNumber } from 'lodash'
|
||||
import { authContextMiddleware, buildContext } from '@/modules/shared/middleware'
|
||||
|
||||
let graphqlServer: ApolloServer
|
||||
|
||||
@@ -104,10 +105,7 @@ function buildApolloSubscriptionServer(
|
||||
// Build context (Apollo Server v3 no longer triggers context building automatically
|
||||
// for subscriptions)
|
||||
try {
|
||||
return await buildContext({
|
||||
connection: { context: { token } },
|
||||
req: undefined
|
||||
})
|
||||
return await buildContext({ req: null, token })
|
||||
} catch (e) {
|
||||
throw new ForbiddenError('Subscription context build failed')
|
||||
}
|
||||
@@ -173,6 +171,7 @@ export async function buildApolloServer(
|
||||
*/
|
||||
export async function init() {
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
|
||||
Logging(app)
|
||||
|
||||
@@ -191,6 +190,16 @@ export async function init() {
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
app.use(express.urlencoded({ limit: '100mb', extended: false }))
|
||||
|
||||
// Trust X-Forwarded-* headers (for https protocol detection)
|
||||
app.enable('trust proxy')
|
||||
|
||||
// Log errors
|
||||
app.use(errorLoggingMiddleware)
|
||||
app.use(authContextMiddleware)
|
||||
app.use(createRateLimiterMiddleware())
|
||||
|
||||
app.use(Sentry.Handlers.errorHandler())
|
||||
|
||||
// Initialize default modules, including rest api handlers
|
||||
await ModulesSetup.init(app)
|
||||
|
||||
@@ -212,12 +221,6 @@ export async function init() {
|
||||
}
|
||||
})
|
||||
|
||||
// Trust X-Forwarded-* headers (for https protocol detection)
|
||||
app.enable('trust proxy')
|
||||
|
||||
// Log errors
|
||||
app.use(errorLoggingMiddleware)
|
||||
|
||||
// Init HTTP server & subscription server
|
||||
const server = http.createServer(app)
|
||||
subscriptionServer = buildApolloSubscriptionServer(graphqlServer, server)
|
||||
@@ -229,6 +232,26 @@ export async function shutdown(): Promise<void> {
|
||||
await ModulesSetup.shutdown()
|
||||
}
|
||||
|
||||
const shouldUseFrontendProxy = () => process.env.NODE_ENV === 'development'
|
||||
|
||||
async function createFrontendProxy() {
|
||||
const frontendHost = process.env.FRONTEND_HOST || 'localhost'
|
||||
const frontendPort = process.env.FRONTEND_PORT || 8080
|
||||
const { createProxyMiddleware } = await import('http-proxy-middleware')
|
||||
|
||||
// even tho it has default values, it fixes http-proxy setting `Connection: close` on each request
|
||||
// slowing everything down
|
||||
const defaultAgent = new http.Agent()
|
||||
|
||||
return createProxyMiddleware({
|
||||
target: `http://${frontendHost}:${frontendPort}`,
|
||||
changeOrigin: true,
|
||||
ws: false,
|
||||
logLevel: 'silent',
|
||||
agent: defaultAgent
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a http server, hoisting the express app to it.
|
||||
*/
|
||||
@@ -240,26 +263,12 @@ export async function startHttp(
|
||||
let bindAddress = process.env.BIND_ADDRESS || '127.0.0.1'
|
||||
let port = process.env.PORT ? toNumber(process.env.PORT) : 3000
|
||||
|
||||
const frontendHost = process.env.FRONTEND_HOST || 'localhost'
|
||||
const frontendPort = process.env.FRONTEND_PORT || 8080
|
||||
|
||||
// Handles frontend proxying:
|
||||
// Dev mode -> proxy form the local webpack server
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { createProxyMiddleware } = await import('http-proxy-middleware')
|
||||
|
||||
// even tho it has default values, it fixes http-proxy setting `Connection: close` on each request
|
||||
// slowing everything down
|
||||
const defaultAgent = new http.Agent()
|
||||
|
||||
const frontendProxy = createProxyMiddleware({
|
||||
target: `http://${frontendHost}:${frontendPort}`,
|
||||
changeOrigin: true,
|
||||
ws: false,
|
||||
logLevel: 'silent',
|
||||
agent: defaultAgent
|
||||
})
|
||||
app.use('/', frontendProxy)
|
||||
if (customPortOverride || customPortOverride === 0) port = customPortOverride
|
||||
if (shouldUseFrontendProxy()) {
|
||||
// app.use('/', frontendProxy)
|
||||
app.use(await createFrontendProxy())
|
||||
|
||||
debug('speckle:startup')('✨ Proxying frontend (dev mode):')
|
||||
debug('speckle:startup')(`👉 main application: http://localhost:${port}/`)
|
||||
@@ -272,11 +281,8 @@ export async function startHttp(
|
||||
|
||||
monitorActiveConnections(server)
|
||||
|
||||
if (customPortOverride || customPortOverride === 0) port = customPortOverride
|
||||
app.set('port', port)
|
||||
|
||||
app.use(Sentry.Handlers.errorHandler())
|
||||
|
||||
// large timeout to allow large downloads on slow connections to finish
|
||||
createTerminus(server, {
|
||||
signals: ['SIGTERM', 'SIGINT'],
|
||||
|
||||
@@ -18,7 +18,7 @@ function passportAuthenticate(strategy, options = undefined) {
|
||||
}
|
||||
|
||||
req.user = user
|
||||
return next()
|
||||
next()
|
||||
})(req, res, next)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ const {
|
||||
getUserByEmail
|
||||
} = require('@/modules/core/services/users')
|
||||
const { getServerInfo } = require('@/modules/core/services/generic')
|
||||
const { respectsLimits } = require('@/modules/core/services/ratelimits')
|
||||
const {
|
||||
sendRateLimitResponse,
|
||||
getRateLimitResult,
|
||||
isRateLimitBreached,
|
||||
RateLimitAction
|
||||
} = require('@/modules/core/services/ratelimiter')
|
||||
const {
|
||||
validateServerInvite,
|
||||
finalizeInvitedServerRegistration,
|
||||
@@ -40,7 +45,7 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => {
|
||||
if (!user) throw new Error('Invalid credentials')
|
||||
req.user = { id: user.id }
|
||||
|
||||
next()
|
||||
return next()
|
||||
} catch (err) {
|
||||
return res.status(401).send({ err: true, message: 'Invalid credentials' })
|
||||
}
|
||||
@@ -60,11 +65,13 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => {
|
||||
const user = req.body
|
||||
const ip = getIpFromRequest(req)
|
||||
if (ip) user.ip = ip
|
||||
if (
|
||||
user.ip &&
|
||||
!(await respectsLimits({ action: 'USER_CREATE', source: user.ip }))
|
||||
) {
|
||||
throw new Error('Blocked due to rate-limiting. Try again later')
|
||||
const source = ip ? ip : 'unknown'
|
||||
const rateLimitResult = await getRateLimitResult(
|
||||
RateLimitAction.USER_CREATE,
|
||||
source
|
||||
)
|
||||
if (isRateLimitBreached(rateLimitResult)) {
|
||||
return sendRateLimitResponse(res, rateLimitResult)
|
||||
}
|
||||
|
||||
// 1. if the server is invite only you must have an invite
|
||||
|
||||
@@ -6,10 +6,17 @@ const { createStream } = require('@/modules/core/services/streams')
|
||||
|
||||
const { updateServerInfo } = require('@/modules/core/services/generic')
|
||||
const { getUserByEmail } = require('@/modules/core/services/users')
|
||||
const { LIMITS } = require('@/modules/core/services/ratelimits')
|
||||
const { TIME } = require('@speckle/shared')
|
||||
const {
|
||||
RATE_LIMITERS,
|
||||
createConsumer,
|
||||
RateLimitAction
|
||||
} = require('@/modules/core/services/ratelimiter')
|
||||
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
|
||||
const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper')
|
||||
const { getInvite } = require('@/modules/serverinvites/repositories')
|
||||
const { RateLimiterMemory } = require('rate-limiter-flexible')
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
let app
|
||||
@@ -455,26 +462,37 @@ describe('Auth @auth', () => {
|
||||
.expect(expectCode)
|
||||
}
|
||||
|
||||
const oldLimit = LIMITS.USER_CREATE
|
||||
LIMITS.USER_CREATE = 5
|
||||
// 5 users should be fine
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await newUser(`test${i}`, '1.2.3.4', 302)
|
||||
}
|
||||
// should fail the 6th user
|
||||
await newUser(`test${5}`, '1.2.3.4', 400)
|
||||
const oldRateLimiter = RATE_LIMITERS.USER_CREATE
|
||||
|
||||
RATE_LIMITERS.USER_CREATE = createConsumer(
|
||||
RateLimitAction.USER_CREATE,
|
||||
new RateLimiterMemory({
|
||||
keyPrefix: RateLimitAction.USER_CREATE,
|
||||
points: 1,
|
||||
duration: 1 * TIME.week
|
||||
})
|
||||
)
|
||||
|
||||
const oldNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'temporarily-disabled-test'
|
||||
|
||||
// 1 users should be fine
|
||||
await newUser(`test0`, '1.2.3.4', 302)
|
||||
|
||||
// should fail the next user
|
||||
await newUser(`test1`, '1.2.3.4', 429)
|
||||
|
||||
// should be able to create from different ip
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await newUser(`othertest${i}`, '1.2.3.5', 302)
|
||||
}
|
||||
await newUser(`othertest0`, '1.2.3.5', 302)
|
||||
|
||||
// should not be limited from unknown ip addresses
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await newUser(`generic${i}`, '', 302)
|
||||
}
|
||||
// should be limited from unknown ip addresses
|
||||
await newUser(`unknown0`, '', 302)
|
||||
|
||||
LIMITS.USER_CREATE = oldLimit
|
||||
// should fail the additional user from unknown ip address
|
||||
await newUser(`unknown1`, '', 429)
|
||||
|
||||
RATE_LIMITERS.USER_CREATE = oldRateLimiter
|
||||
process.env.NODE_ENV = oldNodeEnv
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const debug = require('debug')
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const Busboy = require('busboy')
|
||||
const {
|
||||
authMiddlewareCreator,
|
||||
streamReadPermissions,
|
||||
streamWritePermissions,
|
||||
allowForAllRegisteredUsersOnPublicStreamsWithPublicComments,
|
||||
@@ -17,6 +15,7 @@ const {
|
||||
getObjectAttributes
|
||||
} = require('@/modules/blobstorage/objectStorage')
|
||||
const crs = require('crypto-random-string')
|
||||
const { authMiddlewareCreator } = require('@/modules/shared/middleware')
|
||||
|
||||
const {
|
||||
uploadFileStream,
|
||||
@@ -75,7 +74,6 @@ exports.init = async (app) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.post(
|
||||
'/api/stream/:streamId/blob',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator([
|
||||
...streamWritePermissions,
|
||||
// todo should we add public comments upload escape hatch?
|
||||
@@ -170,7 +168,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.post(
|
||||
'/api/stream/:streamId/blob/diff',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator([
|
||||
...streamReadPermissions,
|
||||
allowForAllRegisteredUsersOnPublicStreamsWithPublicComments,
|
||||
@@ -194,7 +191,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.get(
|
||||
'/api/stream/:streamId/blob/:blobId',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator([
|
||||
...streamReadPermissions,
|
||||
allowForAllRegisteredUsersOnPublicStreamsWithPublicComments,
|
||||
@@ -223,7 +219,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.delete(
|
||||
'/api/stream/:streamId/blob/:blobId',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator(streamWritePermissions),
|
||||
async (req, res) => {
|
||||
errorHandler(req, res, async (req, res) => {
|
||||
@@ -239,7 +234,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.get(
|
||||
'/api/stream/:streamId/blobs',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator(streamWritePermissions),
|
||||
async (req, res) => {
|
||||
const fileName = req.query.fileName
|
||||
@@ -257,7 +251,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.delete(
|
||||
'/api/stream/:streamId/blobs',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator(streamWritePermissions)
|
||||
// async (req, res) => {}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { buildApolloServer } = require('@/app')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { addLoadersToCtx } = require('@/modules/shared/middleware')
|
||||
const { truncateTables } = require('@/test/hooks')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { createStream } = require('@/modules/core/services/streams')
|
||||
|
||||
@@ -6,7 +6,7 @@ const debug = require('debug')
|
||||
let unsubFromEvents
|
||||
|
||||
exports.init = async (_, isInitial) => {
|
||||
debug('speckle:modules')('🗣 Init comments module')
|
||||
debug('speckle:modules')('🗣 Init comments module')
|
||||
|
||||
if (isInitial) {
|
||||
unsubFromEvents = await notifyUsersOnCommentEvents()
|
||||
|
||||
@@ -2,7 +2,7 @@ const expect = require('chai').expect
|
||||
|
||||
const crs = require('crypto-random-string')
|
||||
const { buildApolloServer } = require('@/app')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { addLoadersToCtx } = require('@/modules/shared/middleware')
|
||||
const { beforeEachContext } = require('@/test/hooks')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const {
|
||||
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
} = require('@/modules/comments/services/commentTextService')
|
||||
const { range } = require('lodash')
|
||||
const { buildApolloServer } = require('@/app')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { addLoadersToCtx } = require('@/modules/shared/middleware')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { createAuthTokenForUser } = require('@/test/authHelper')
|
||||
const { uploadBlob } = require('@/test/blobHelper')
|
||||
@@ -823,8 +823,6 @@ describe('Comments @comments', () => {
|
||||
expect(commentText.doc).to.deep.equalInAnyOrder(properText)
|
||||
})
|
||||
|
||||
it('Should be able to toggle reactions for a comment')
|
||||
|
||||
it('Should be able to archive a comment', async () => {
|
||||
const { id: commentId } = await createComment({
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RateLimitBreached } from '@/modules/core/services/ratelimiter'
|
||||
import { BaseError } from '@/modules/shared/errors'
|
||||
import { Options } from 'verror'
|
||||
|
||||
export class RateLimitError extends BaseError {
|
||||
static defaultMessage =
|
||||
'You have sent too many requests. You are being rate limited. Please try again later.'
|
||||
static code = 'RATE_LIMIT_ERROR'
|
||||
|
||||
rateLimitBreached: RateLimitBreached
|
||||
|
||||
constructor(
|
||||
rateLimitBreached: RateLimitBreached,
|
||||
message?: string | undefined,
|
||||
options?: Options | Error | undefined
|
||||
) {
|
||||
super(message || RateLimitError.defaultMessage, options)
|
||||
this.rateLimitBreached = rateLimitBreached
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,12 @@ const {
|
||||
|
||||
const { getUser } = require('../../services/users')
|
||||
|
||||
const { respectsLimits } = require('../../services/ratelimits')
|
||||
const {
|
||||
isRateLimitBreached,
|
||||
getRateLimitResult,
|
||||
RateLimitError,
|
||||
RateLimitAction
|
||||
} = require('@/modules/core/services/ratelimiter')
|
||||
const {
|
||||
batchMoveCommits,
|
||||
batchDeleteCommits
|
||||
@@ -156,10 +161,12 @@ module.exports = {
|
||||
'stream:contributor'
|
||||
)
|
||||
|
||||
if (
|
||||
!(await respectsLimits({ action: 'COMMIT_CREATE', source: context.userId }))
|
||||
) {
|
||||
throw new Error('Blocked due to rate-limiting. Try again later')
|
||||
const rateLimitResult = await getRateLimitResult(
|
||||
RateLimitAction.COMMIT_CREATE,
|
||||
context.userId
|
||||
)
|
||||
if (isRateLimitBreached(rateLimitResult)) {
|
||||
throw new RateLimitError(rateLimitResult)
|
||||
}
|
||||
|
||||
const id = await createCommitByBranchName({
|
||||
|
||||
@@ -25,7 +25,12 @@ const {
|
||||
} = require(`@/modules/shared`)
|
||||
const { saveActivity } = require(`@/modules/activitystream/services`)
|
||||
const { ActionTypes } = require('@/modules/activitystream/helpers/types')
|
||||
const { respectsLimits } = require('@/modules/core/services/ratelimits')
|
||||
const {
|
||||
RateLimitError,
|
||||
RateLimitAction,
|
||||
getRateLimitResult,
|
||||
isRateLimitBreached
|
||||
} = require('@/modules/core/services/ratelimiter')
|
||||
const {
|
||||
getPendingStreamCollaborators
|
||||
} = require('@/modules/serverinvites/services/inviteRetrievalService')
|
||||
@@ -230,10 +235,12 @@ module.exports = {
|
||||
},
|
||||
Mutation: {
|
||||
async streamCreate(parent, args, context) {
|
||||
if (
|
||||
!(await respectsLimits({ action: 'STREAM_CREATE', source: context.userId }))
|
||||
) {
|
||||
throw new Error('Blocked due to rate-limiting. Try again later')
|
||||
const rateLimitResult = await getRateLimitResult(
|
||||
RateLimitAction.STREAM_CREATE,
|
||||
context.userId
|
||||
)
|
||||
if (isRateLimitBreached(rateLimitResult)) {
|
||||
throw new RateLimitError(rateLimitResult)
|
||||
}
|
||||
|
||||
const id = await createStream({ ...args.stream, ownerId: context.userId })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Nullable } from '@/modules/shared/helpers/typeHelper'
|
||||
import { ServerRoles } from '@speckle/shared'
|
||||
|
||||
export type UserRecord = {
|
||||
id: string
|
||||
@@ -120,3 +121,16 @@ export type ObjectRecord = {
|
||||
data: Nullable<Record<string, unknown>>
|
||||
streamId: string
|
||||
}
|
||||
|
||||
export type InvalidTokenResult = {
|
||||
valid: false
|
||||
}
|
||||
|
||||
export type ValidTokenResult = {
|
||||
valid: true
|
||||
scopes: string[]
|
||||
userId: string
|
||||
role: ServerRoles
|
||||
}
|
||||
|
||||
export type TokenValidationResult = InvalidTokenResult | ValidTokenResult
|
||||
|
||||
@@ -3,26 +3,16 @@ const zlib = require('zlib')
|
||||
const debug = require('debug')
|
||||
const cors = require('cors')
|
||||
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsReadStream } = require('./authUtils')
|
||||
const { SpeckleObjectsStream } = require('./speckleObjectsStream')
|
||||
const { getObjectsStream } = require('../services/objects')
|
||||
const {
|
||||
rejectsRequestWithRatelimitStatusIfNeeded
|
||||
} = require('@/modules/core/services/ratelimits')
|
||||
|
||||
const { pipeline, PassThrough } = require('stream')
|
||||
|
||||
module.exports = (app) => {
|
||||
app.options('/api/getobjects/:streamId', cors())
|
||||
|
||||
app.post('/api/getobjects/:streamId', cors(), contextMiddleware, async (req, res) => {
|
||||
const rejected = await rejectsRequestWithRatelimitStatusIfNeeded({
|
||||
action: 'POST /api/getobjects/:streamId',
|
||||
req,
|
||||
res
|
||||
})
|
||||
if (rejected) return rejected
|
||||
app.post('/api/getobjects/:streamId', cors(), async (req, res) => {
|
||||
const hasStreamAccess = await validatePermissionsReadStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
|
||||
@@ -3,24 +3,14 @@ const zlib = require('zlib')
|
||||
const cors = require('cors')
|
||||
const debug = require('debug')
|
||||
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsWriteStream } = require('./authUtils')
|
||||
const {
|
||||
rejectsRequestWithRatelimitStatusIfNeeded
|
||||
} = require('@/modules/core/services/ratelimits')
|
||||
|
||||
const { hasObjects } = require('../services/objects')
|
||||
|
||||
module.exports = (app) => {
|
||||
app.options('/api/diff/:streamId', cors())
|
||||
|
||||
app.post('/api/diff/:streamId', cors(), contextMiddleware, async (req, res) => {
|
||||
const rejected = await rejectsRequestWithRatelimitStatusIfNeeded({
|
||||
action: 'POST /api/diff/:streamId',
|
||||
req,
|
||||
res
|
||||
})
|
||||
if (rejected) return rejected
|
||||
app.post('/api/diff/:streamId', cors(), async (req, res) => {
|
||||
const hasStreamAccess = await validatePermissionsWriteStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
|
||||
@@ -3,129 +3,101 @@ const zlib = require('zlib')
|
||||
const debug = require('debug')
|
||||
const cors = require('cors')
|
||||
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsReadStream } = require('./authUtils')
|
||||
|
||||
const { getObject, getObjectChildrenStream } = require('../services/objects')
|
||||
const { SpeckleObjectsStream } = require('./speckleObjectsStream')
|
||||
const { pipeline, PassThrough } = require('stream')
|
||||
const {
|
||||
rejectsRequestWithRatelimitStatusIfNeeded
|
||||
} = require('@/modules/core/services/ratelimits')
|
||||
|
||||
module.exports = (app) => {
|
||||
app.options('/objects/:streamId/:objectId', cors())
|
||||
|
||||
app.get(
|
||||
'/objects/:streamId/:objectId',
|
||||
cors(),
|
||||
contextMiddleware,
|
||||
async (req, res) => {
|
||||
const rejected = await rejectsRequestWithRatelimitStatusIfNeeded({
|
||||
action: 'GET /objects/:streamId/:objectId',
|
||||
req,
|
||||
res
|
||||
})
|
||||
if (rejected) return rejected
|
||||
|
||||
const hasStreamAccess = await validatePermissionsReadStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
)
|
||||
if (!hasStreamAccess.result) {
|
||||
return res.status(hasStreamAccess.status).end()
|
||||
}
|
||||
|
||||
// Populate first object (the "commit")
|
||||
const obj = await getObject({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
|
||||
if (!obj) {
|
||||
return res.status(404).send('Failed to find object.')
|
||||
}
|
||||
|
||||
const simpleText = req.headers.accept === 'text/plain'
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Type': simpleText ? 'text/plain; charset=UTF-8' : 'application/json'
|
||||
})
|
||||
|
||||
const dbStream = await getObjectChildrenStream({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
const speckleObjStream = new SpeckleObjectsStream(simpleText)
|
||||
const gzipStream = zlib.createGzip()
|
||||
|
||||
speckleObjStream.write(obj)
|
||||
|
||||
pipeline(
|
||||
dbStream,
|
||||
speckleObjStream,
|
||||
gzipStream,
|
||||
new PassThrough({ highWaterMark: 16384 * 31 }),
|
||||
res,
|
||||
(err) => {
|
||||
if (err) {
|
||||
debug('speckle:error')(
|
||||
`[User ${req.context.userId || '-'}] Error downloading object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId}: ${err}`
|
||||
)
|
||||
} else {
|
||||
debug('speckle:info')(
|
||||
`[User ${req.context.userId || '-'}] Downloaded object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId} (size: ${
|
||||
gzipStream.bytesWritten / 1000000
|
||||
} MB)`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
app.get('/objects/:streamId/:objectId', cors(), async (req, res) => {
|
||||
const hasStreamAccess = await validatePermissionsReadStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
)
|
||||
if (!hasStreamAccess.result) {
|
||||
return res.status(hasStreamAccess.status).end()
|
||||
}
|
||||
)
|
||||
|
||||
// Populate first object (the "commit")
|
||||
const obj = await getObject({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
|
||||
if (!obj) {
|
||||
return res.status(404).send('Failed to find object.')
|
||||
}
|
||||
|
||||
const simpleText = req.headers.accept === 'text/plain'
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Type': simpleText ? 'text/plain; charset=UTF-8' : 'application/json'
|
||||
})
|
||||
|
||||
const dbStream = await getObjectChildrenStream({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
const speckleObjStream = new SpeckleObjectsStream(simpleText)
|
||||
const gzipStream = zlib.createGzip()
|
||||
|
||||
speckleObjStream.write(obj)
|
||||
|
||||
pipeline(
|
||||
dbStream,
|
||||
speckleObjStream,
|
||||
gzipStream,
|
||||
new PassThrough({ highWaterMark: 16384 * 31 }),
|
||||
res,
|
||||
(err) => {
|
||||
if (err) {
|
||||
debug('speckle:error')(
|
||||
`[User ${req.context.userId || '-'}] Error downloading object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId}: ${err}`
|
||||
)
|
||||
} else {
|
||||
debug('speckle:info')(
|
||||
`[User ${req.context.userId || '-'}] Downloaded object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId} (size: ${
|
||||
gzipStream.bytesWritten / 1000000
|
||||
} MB)`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
app.options('/objects/:streamId/:objectId/single', cors())
|
||||
app.get(
|
||||
'/objects/:streamId/:objectId/single',
|
||||
cors(),
|
||||
contextMiddleware,
|
||||
async (req, res) => {
|
||||
const rejected = await rejectsRequestWithRatelimitStatusIfNeeded({
|
||||
action: 'GET /objects/:streamId/:objectId/single',
|
||||
req,
|
||||
res
|
||||
})
|
||||
if (rejected) return rejected
|
||||
|
||||
const hasStreamAccess = await validatePermissionsReadStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
)
|
||||
if (!hasStreamAccess.result) {
|
||||
return res.status(hasStreamAccess.status).end()
|
||||
}
|
||||
|
||||
const obj = await getObject({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
|
||||
if (!obj) {
|
||||
return res.status(404).send('Failed to find object.')
|
||||
}
|
||||
|
||||
debug('speckle:info')(
|
||||
`[User ${req.context.userId || '-'}] Downloaded single object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId}`
|
||||
)
|
||||
|
||||
res.send(obj.data)
|
||||
app.get('/objects/:streamId/:objectId/single', cors(), async (req, res) => {
|
||||
const hasStreamAccess = await validatePermissionsReadStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
)
|
||||
if (!hasStreamAccess.result) {
|
||||
return res.status(hasStreamAccess.status).end()
|
||||
}
|
||||
)
|
||||
|
||||
const obj = await getObject({
|
||||
streamId: req.params.streamId,
|
||||
objectId: req.params.objectId
|
||||
})
|
||||
|
||||
if (!obj) {
|
||||
return res.status(404).send('Failed to find object.')
|
||||
}
|
||||
|
||||
debug('speckle:info')(
|
||||
`[User ${req.context.userId || '-'}] Downloaded single object ${
|
||||
req.params.objectId
|
||||
} from stream ${req.params.streamId}`
|
||||
)
|
||||
|
||||
res.send(obj.data)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,27 +4,16 @@ const cors = require('cors')
|
||||
const Busboy = require('busboy')
|
||||
const debug = require('debug')
|
||||
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { validatePermissionsWriteStream } = require('./authUtils')
|
||||
|
||||
const { createObjectsBatched } = require('../services/objects')
|
||||
const {
|
||||
rejectsRequestWithRatelimitStatusIfNeeded
|
||||
} = require('@/modules/core/services/ratelimits')
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
|
||||
module.exports = (app) => {
|
||||
app.options('/objects/:streamId', cors())
|
||||
|
||||
app.post('/objects/:streamId', cors(), contextMiddleware, async (req, res) => {
|
||||
const rejected = await rejectsRequestWithRatelimitStatusIfNeeded({
|
||||
action: 'POST /objects/:streamId',
|
||||
req,
|
||||
res
|
||||
})
|
||||
if (rejected) return rejected
|
||||
|
||||
app.post('/objects/:streamId', cors(), async (req, res) => {
|
||||
const hasStreamAccess = await validatePermissionsWriteStream(
|
||||
req.params.streamId,
|
||||
req
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import express, { RequestWithAuthContext } from 'express'
|
||||
import Redis from 'ioredis'
|
||||
import {
|
||||
getRedisUrl,
|
||||
getIntFromEnv,
|
||||
isTestEnv
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import {
|
||||
BurstyRateLimiter,
|
||||
RateLimiterAbstract,
|
||||
RateLimiterMemory,
|
||||
RateLimiterRedis,
|
||||
RateLimiterRes
|
||||
} from 'rate-limiter-flexible'
|
||||
import { TIME } from '@speckle/shared'
|
||||
import { getIpFromRequest } from '@/modules/shared/utils/ip'
|
||||
import { RateLimitError } from '@/modules/core/errors/ratelimit'
|
||||
|
||||
// typescript definitions
|
||||
export enum RateLimitAction {
|
||||
ALL_REQUESTS = 'ALL_REQUESTS',
|
||||
USER_CREATE = 'USER_CREATE',
|
||||
STREAM_CREATE = 'STREAM_CREATE',
|
||||
COMMIT_CREATE = 'COMMIT_CREATE',
|
||||
'POST /api/getobjects/:streamId' = 'POST /api/getobjects/:streamId',
|
||||
'POST /api/diff/:streamId' = 'POST /api/diff/:streamId',
|
||||
'POST /objects/:streamId' = 'POST /objects/:streamId',
|
||||
'GET /objects/:streamId/:objectId' = 'GET /objects/:streamId/:objectId',
|
||||
'GET /objects/:streamId/:objectId/single' = 'GET /objects/:streamId/:objectId/single',
|
||||
'POST /graphql' = 'POST /graphql'
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
isWithinLimits: boolean
|
||||
action: RateLimitAction
|
||||
}
|
||||
|
||||
export interface RateLimitSuccess extends RateLimitResult {
|
||||
isWithinLimits: true
|
||||
remainingPoints: number
|
||||
}
|
||||
|
||||
export interface RateLimitBreached extends RateLimitResult {
|
||||
isWithinLimits: false
|
||||
msBeforeNext: number
|
||||
}
|
||||
|
||||
type BurstyRateLimiterOptions = {
|
||||
regularOptions: RateLimits
|
||||
burstOptions: RateLimits
|
||||
}
|
||||
|
||||
export type RateLimits = {
|
||||
limitCount: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
type RateLimiterOptions = {
|
||||
[key in RateLimitAction]: BurstyRateLimiterOptions
|
||||
}
|
||||
|
||||
export type RateLimiterMapping = {
|
||||
[key in RateLimitAction]: (
|
||||
source: string
|
||||
) => Promise<RateLimitSuccess | RateLimitBreached>
|
||||
}
|
||||
|
||||
export const LIMITS: RateLimiterOptions = {
|
||||
ALL_REQUESTS: {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_ALL_REQUESTS', '500'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_ALL_REQUESTS', '2000'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
USER_CREATE: {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_USER_CREATE', '6'),
|
||||
duration: 1 * TIME.hour
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_USER_CREATE', '1000'),
|
||||
duration: 1 * TIME.week
|
||||
}
|
||||
},
|
||||
STREAM_CREATE: {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_STREAM_CREATE', '1'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_STREAM_CREATE', '100'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
COMMIT_CREATE: {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_COMMIT_CREATE', '1'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_COMMIT_CREATE', '100'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'POST /api/getobjects/:streamId': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_POST_GETOBJECTS_STREAMID', '3'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_GETOBJECTS_STREAMID', '200'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'POST /api/diff/:streamId': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_POST_DIFF_STREAMID', '10'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_DIFF_STREAMID', '1000'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'POST /objects/:streamId': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_POST_OBJECTS_STREAMID', '6'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_OBJECTS_STREAMID', '400'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'GET /objects/:streamId/:objectId': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_GET_OBJECTS_STREAMID_OBJECTID', '3'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_OBJECTS_STREAMID_OBJECTID', '200'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'GET /objects/:streamId/:objectId/single': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_GET_OBJECTS_STREAMID_OBJECTID_SINGLE', '3'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv(
|
||||
'RATELIMIT_BURST_GET_OBJECTS_STREAMID_OBJECTID_SINGLE',
|
||||
'200'
|
||||
),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
},
|
||||
'POST /graphql': {
|
||||
regularOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_POST_GRAPHQL', '50'),
|
||||
duration: 1 * TIME.second
|
||||
},
|
||||
burstOptions: {
|
||||
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_GRAPHQL', '200'),
|
||||
duration: 1 * TIME.minute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sendRateLimitResponse = (
|
||||
res: express.Response,
|
||||
rateLimitBreached: RateLimitBreached
|
||||
): express.Response => {
|
||||
res.setHeader('Retry-After', rateLimitBreached.msBeforeNext / 1000)
|
||||
res.removeHeader('X-RateLimit-Remaining')
|
||||
res.setHeader(
|
||||
'X-RateLimit-Reset',
|
||||
new Date(Date.now() + rateLimitBreached.msBeforeNext).toISOString()
|
||||
)
|
||||
res.setHeader('X-Speckle-Meditation', 'https://http.cat/429')
|
||||
return res.status(429).send({
|
||||
err: 'You are sending too many requests. You have been rate limited. Please try again later.'
|
||||
})
|
||||
}
|
||||
|
||||
export const getActionForPath = (path: string, verb: string): RateLimitAction => {
|
||||
const maybeAction = `${verb} ${path}` as keyof typeof RateLimitAction
|
||||
return RateLimitAction[maybeAction] || RateLimitAction.ALL_REQUESTS
|
||||
}
|
||||
|
||||
export const getSourceFromRequest = (req: express.Request): string => {
|
||||
let source: string | null =
|
||||
((req as RequestWithAuthContext)?.context?.userId as string) ||
|
||||
getIpFromRequest(req)
|
||||
|
||||
if (!source) source = 'unknown'
|
||||
return source
|
||||
}
|
||||
|
||||
export const createRateLimiterMiddleware = (
|
||||
rateLimiterMapping: RateLimiterMapping = RATE_LIMITERS
|
||||
) => {
|
||||
return async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
if (isTestEnv()) return next()
|
||||
const path = req.originalUrl ? req.originalUrl : req.path
|
||||
const action = getActionForPath(path, req.method)
|
||||
const source = getSourceFromRequest(req)
|
||||
|
||||
const rateLimitResult = await getRateLimitResult(action, source, rateLimiterMapping)
|
||||
if (isRateLimitBreached(rateLimitResult)) {
|
||||
return sendRateLimitResponse(res, rateLimitResult)
|
||||
} else {
|
||||
try {
|
||||
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remainingPoints)
|
||||
return next()
|
||||
} catch (err) {
|
||||
if (!(err instanceof RateLimitError)) throw err
|
||||
return sendRateLimitResponse(res, err.rateLimitBreached)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we need to take the `BurstyRateLimiter` specific type because
|
||||
// its not considered as an RateLimiterAbstract in the rate-limiter-flexible package
|
||||
// This is just a rant comment, but why define the Abstract then if not
|
||||
// all RateLimiters are implementing it?
|
||||
export const createConsumer =
|
||||
(action: RateLimitAction, rateLimiter: RateLimiterAbstract | BurstyRateLimiter) =>
|
||||
async (source: string): Promise<RateLimitSuccess | RateLimitBreached> => {
|
||||
try {
|
||||
const rateLimitRes = await rateLimiter.consume(source)
|
||||
return {
|
||||
action,
|
||||
isWithinLimits: true,
|
||||
remainingPoints: rateLimitRes.remainingPoints
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimiterRes)
|
||||
return { action, isWithinLimits: false, msBeforeNext: err.msBeforeNext }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export const initializeRedisRateLimiters = (
|
||||
options: RateLimiterOptions = LIMITS
|
||||
): RateLimiterMapping => {
|
||||
const redisClient = new Redis(getRedisUrl(), {
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: null
|
||||
})
|
||||
const allActions = Object.values(RateLimitAction)
|
||||
const mapping = Object.fromEntries(
|
||||
allActions.map((action) => {
|
||||
const limits = options[action]
|
||||
const burstyLimiter = new BurstyRateLimiter(
|
||||
new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: action,
|
||||
points: limits.regularOptions.limitCount,
|
||||
duration: limits.regularOptions.duration,
|
||||
inMemoryBlockOnConsumed: limits.regularOptions.limitCount, // stops additional requests going to Redis once the limit is reached
|
||||
inMemoryBlockDuration: limits.regularOptions.duration,
|
||||
insuranceLimiter: new RateLimiterMemory({
|
||||
keyPrefix: action,
|
||||
points: limits.regularOptions.limitCount,
|
||||
duration: limits.regularOptions.duration
|
||||
})
|
||||
}),
|
||||
new RateLimiterRedis({
|
||||
storeClient: redisClient,
|
||||
keyPrefix: `BURST_${action}`,
|
||||
points: limits.burstOptions.limitCount,
|
||||
duration: limits.burstOptions.duration,
|
||||
inMemoryBlockOnConsumed: limits.burstOptions.limitCount,
|
||||
inMemoryBlockDuration: limits.burstOptions.duration,
|
||||
insuranceLimiter: new RateLimiterMemory({
|
||||
keyPrefix: `BURST_${action}`,
|
||||
points: limits.burstOptions.limitCount,
|
||||
duration: limits.burstOptions.duration
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return [action, createConsumer(action, burstyLimiter)]
|
||||
})
|
||||
)
|
||||
// i know that all the values are in there, but TS doesn't...
|
||||
return mapping as RateLimiterMapping
|
||||
}
|
||||
|
||||
export const RATE_LIMITERS = initializeRedisRateLimiters()
|
||||
|
||||
export const isRateLimitBreached = (
|
||||
rateLimitResult: RateLimitResult
|
||||
): rateLimitResult is RateLimitBreached => !rateLimitResult.isWithinLimits
|
||||
|
||||
export async function getRateLimitResult(
|
||||
action: RateLimitAction,
|
||||
source: string,
|
||||
rateLimiterMapping: RateLimiterMapping = RATE_LIMITERS
|
||||
): Promise<RateLimitSuccess | RateLimitBreached> {
|
||||
const consumerFunc = rateLimiterMapping[action]
|
||||
return await consumerFunc(source)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use strict'
|
||||
const knex = require('@/db/knex')
|
||||
|
||||
const RatelimitActions = () => knex('ratelimit_actions')
|
||||
const prometheusClient = require('prom-client')
|
||||
|
||||
const limitsReached = new prometheusClient.Counter({
|
||||
name: 'speckle_server_blocked_ratelimit',
|
||||
help: 'Number of time the requests were blocked',
|
||||
labelNames: ['actionName']
|
||||
})
|
||||
|
||||
const LIMITS = {
|
||||
// rate limits:
|
||||
USER_CREATE: parseInt(process.env.RATELIMIT_USER_CREATE) || 1000, // per week
|
||||
STREAM_CREATE: parseInt(process.env.RATELIMIT_STREAM_CREATE) || 10000, // per week (1 stream / minute average)
|
||||
COMMIT_CREATE: parseInt(process.env.RATELIMIT_COMMIT_CREATE) || 86400, // per day (1 commit every second average)
|
||||
// unused:
|
||||
SUBSCRIPTION: parseInt(process.env.RATELIMIT_SUBSCRIPTION) || 600, // per minute
|
||||
REST_API: parseInt(process.env.RATELIMIT_REST_API) || 2400, // per minute
|
||||
WEBHOOKS: parseInt(process.env.RATELIMIT_WEBHOOKS) || 1000, // per day
|
||||
PREVIEWS: parseInt(process.env.RATELIMIT_PREVIEWS) || 1000, // per day
|
||||
FILE_UPLOADS: parseInt(process.env.RATELIMIT_FILE_UPLOADS) || 1000, // per day
|
||||
// static limits:
|
||||
BRANCHES: parseInt(process.env.LIMIT_BRANCHES) || 1000, // per stream
|
||||
TOKENS: parseInt(process.env.LIMIT_TOKENS) || 1000, // per user
|
||||
ACTIVE_SUBSCRIPTIONS: parseInt(process.env.LIMIT_ACTIVE_SUBSCRIPTIONS) || 100, // per user
|
||||
ACTIVE_CONNECTIONS: parseInt(process.env.LIMIT_ACTIVE_CONNECTIONS) || 100, // per source ip
|
||||
|
||||
'POST /api/getobjects/:streamId': 200, // for 1 minute
|
||||
'POST /api/diff/:streamId': 200, // for 1 minute
|
||||
'POST /objects/:streamId': 200, // for 1 minute
|
||||
'GET /objects/:streamId/:objectId': 200, // for 1 minute
|
||||
'GET /objects/:streamId/:objectId/single': 200 // for 1 minute
|
||||
}
|
||||
|
||||
const LIMIT_INTERVAL = {
|
||||
// rate limits
|
||||
USER_CREATE: 7 * 24 * 3600,
|
||||
STREAM_CREATE: 7 * 24 * 3600,
|
||||
COMMIT_CREATE: 24 * 3600,
|
||||
SUBSCRIPTION: 60,
|
||||
REST_API: 60,
|
||||
WEBHOOKS: 24 * 3600,
|
||||
PREVIEWS: 24 * 3600,
|
||||
FILE_UPLOADS: 24 * 3600,
|
||||
// static limits:
|
||||
BRANCHES: 0,
|
||||
TOKENS: 0,
|
||||
ACTIVE_SUBSCRIPTIONS: 0,
|
||||
ACTIVE_CONNECTIONS: 0,
|
||||
|
||||
'POST /api/getobjects/:streamId': 60,
|
||||
'POST /api/diff/:streamId': 60,
|
||||
'POST /objects/:streamId': 60,
|
||||
'GET /objects/:streamId/:objectId': 60,
|
||||
'GET /objects/:streamId/:objectId/single': 60
|
||||
}
|
||||
|
||||
const rateLimitedCache = {}
|
||||
|
||||
async function shouldRateLimitNext({ action, source }) {
|
||||
if (!source) return false
|
||||
|
||||
const limit = LIMITS[action]
|
||||
const checkInterval = LIMIT_INTERVAL[action]
|
||||
if (limit === undefined || checkInterval === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
let startTimeMs
|
||||
if (checkInterval === 0) startTimeMs = 0
|
||||
else startTimeMs = Date.now() - checkInterval * 1000
|
||||
|
||||
const [res] = await RatelimitActions()
|
||||
.count()
|
||||
.where({ action, source })
|
||||
.andWhere('timestamp', '>', new Date(startTimeMs))
|
||||
const count = parseInt(res.count) + 1 // plus this request
|
||||
|
||||
const shouldRateLimit = count >= limit
|
||||
|
||||
if (!shouldRateLimit) {
|
||||
await RatelimitActions().insert({ action, source })
|
||||
}
|
||||
return shouldRateLimit
|
||||
}
|
||||
|
||||
// returns true if the action is fine, false if it should be blocked because of exceeding limit
|
||||
async function respectsLimits({ action, source }) {
|
||||
const rateLimitKey = `${action} ${source}`
|
||||
const promise = shouldRateLimitNext({ action, source }).then((shouldRateLimit) => {
|
||||
if (shouldRateLimit) rateLimitedCache[rateLimitKey] = true
|
||||
else delete rateLimitedCache[rateLimitKey]
|
||||
})
|
||||
if (rateLimitedCache[rateLimitKey]) {
|
||||
await promise
|
||||
}
|
||||
|
||||
if (rateLimitedCache[rateLimitKey]) limitsReached.labels(action).inc()
|
||||
return !rateLimitedCache[rateLimitKey]
|
||||
}
|
||||
|
||||
async function rejectsRequestWithRatelimitStatusIfNeeded({ action, req, res }) {
|
||||
const source = req.context.userId || req.context.ip
|
||||
if (!(await respectsLimits({ action, source })))
|
||||
return res.status(429).set('X-Speckle-Meditation', 'https://http.cat/429').send({
|
||||
err: 'You are sending too many requests. You have been rate limited. Please try again later.'
|
||||
})
|
||||
}
|
||||
module.exports = {
|
||||
LIMITS,
|
||||
LIMIT_INTERVAL,
|
||||
respectsLimits,
|
||||
rejectsRequestWithRatelimitStatusIfNeeded
|
||||
}
|
||||
@@ -65,6 +65,11 @@ module.exports = {
|
||||
return token
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} tokenString
|
||||
* @returns {Promise<import('@/modules/core/helpers/types').TokenValidationResult>}
|
||||
*/
|
||||
async validateToken(tokenString) {
|
||||
const tokenId = tokenString.slice(0, 10)
|
||||
const tokenContent = tokenString.slice(10, 42)
|
||||
|
||||
@@ -6,9 +6,10 @@ const { StreamFavorites, Streams, Users } = require('@/modules/core/dbSchema')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { createStream } = require('@/modules/core/services/streams')
|
||||
const { createUser } = require('@/modules/core/services/users')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { addLoadersToCtx } = require('@/modules/shared/middleware')
|
||||
const { truncateTables } = require('@/test/hooks')
|
||||
const { gql } = require('apollo-server-express')
|
||||
const { sleep } = require('@/test/helpers')
|
||||
|
||||
/**
|
||||
* Cleaning up relevant tables
|
||||
@@ -174,7 +175,9 @@ describe('Favorite streams', () => {
|
||||
it(`can be favorited if ${msgSuffix}`, async () => {
|
||||
const streamId = id()
|
||||
const beforeTime = Date.now()
|
||||
await sleep(1)
|
||||
const result = await favoriteStream(streamId, true)
|
||||
await sleep(1)
|
||||
const afterTime = Date.now()
|
||||
|
||||
expect(result.errors).to.not.be.ok
|
||||
|
||||
@@ -5,10 +5,10 @@ const { beforeEachContext } = require('@/test/hooks')
|
||||
|
||||
const {
|
||||
validateServerRole,
|
||||
buildContext,
|
||||
validateScopes,
|
||||
authorizeResolver
|
||||
} = require('../../shared')
|
||||
} = require('@/modules/shared')
|
||||
const { buildContext } = require('@/modules/shared/middleware')
|
||||
|
||||
describe('Generic AuthN & AuthZ controller tests', () => {
|
||||
before(async () => {
|
||||
@@ -34,17 +34,19 @@ describe('Generic AuthN & AuthZ controller tests', () => {
|
||||
|
||||
await validateScopes(['a', 'b'], 'b') // should pass
|
||||
})
|
||||
|
||||
it('Should create proper context', async () => {
|
||||
const res = await buildContext({ req: { headers: { authorization: 'Bearer BS' } } })
|
||||
expect(res.auth).to.equal(false)
|
||||
|
||||
const res2 = await buildContext({ req: { headers: { authorization: null } } })
|
||||
expect(res2.auth).to.equal(false)
|
||||
|
||||
const res3 = await buildContext({ req: { headers: { authorization: undefined } } })
|
||||
expect(res3.auth).to.equal(false)
|
||||
})
|
||||
;[
|
||||
['BS header', { req: { headers: { authorization: 'Bearer BS' } } }],
|
||||
['Null header', { req: { headers: { authorization: null } } }],
|
||||
['Undefined header', { req: { headers: { authorization: undefined } } }],
|
||||
['BS token', { token: 'Bearer BS' }],
|
||||
['Null token', { token: null }],
|
||||
['Undefined token', { token: undefined }]
|
||||
].map(([caseName, contextInput]) =>
|
||||
it(`Should create proper context ${caseName}`, async () => {
|
||||
const res = await buildContext(contextInput)
|
||||
expect(res.auth).to.equal(false)
|
||||
})
|
||||
)
|
||||
|
||||
it('Should validate server role', async () => {
|
||||
await validateServerRole({ auth: true, role: 'server:user' }, 'server:admin')
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* istanbul ignore file */
|
||||
import { TIME } from '@/../shared/dist-esm'
|
||||
import {
|
||||
createRateLimiterMiddleware,
|
||||
getRateLimitResult,
|
||||
isRateLimitBreached,
|
||||
RateLimitAction,
|
||||
getActionForPath,
|
||||
sendRateLimitResponse,
|
||||
RateLimitBreached,
|
||||
RateLimits,
|
||||
createConsumer,
|
||||
RateLimiterMapping
|
||||
} from '@/modules/core/services/ratelimiter'
|
||||
import { expect } from 'chai'
|
||||
import httpMocks from 'node-mocks-http'
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
type RateLimiterOptions = {
|
||||
[key in RateLimitAction]: RateLimits
|
||||
}
|
||||
|
||||
const initializeInMemoryRateLimiters = (
|
||||
options: RateLimiterOptions
|
||||
): RateLimiterMapping => {
|
||||
const allActions = Object.values(RateLimitAction)
|
||||
const mapping = Object.fromEntries(
|
||||
allActions.map((action) => {
|
||||
const limits = options[action]
|
||||
const limiter = new RateLimiterMemory({
|
||||
keyPrefix: action,
|
||||
points: limits.limitCount,
|
||||
duration: limits.duration
|
||||
})
|
||||
|
||||
return [action, createConsumer(action, limiter)]
|
||||
})
|
||||
)
|
||||
return mapping as RateLimiterMapping
|
||||
}
|
||||
|
||||
const createTestRateLimiterMappings = () => {
|
||||
const allActions = Object.values(RateLimitAction)
|
||||
const mapping = Object.fromEntries(
|
||||
allActions.map((action) => {
|
||||
return [action, { limitCount: 0, duration: 1 * TIME.week }]
|
||||
})
|
||||
)
|
||||
const rateLimiterOptions = mapping as RateLimiterOptions
|
||||
return initializeInMemoryRateLimiters(rateLimiterOptions)
|
||||
}
|
||||
|
||||
const generateRandomIP = () => {
|
||||
return `${Math.floor(Math.random() * 255) + 1}.${Math.floor(
|
||||
Math.random() * 255
|
||||
)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`
|
||||
}
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
describe('isRateLimitBreached', () => {
|
||||
it('should rate limit known actions', async () => {
|
||||
const rateLimiterMapping = createTestRateLimiterMappings()
|
||||
const result = await getRateLimitResult(
|
||||
RateLimitAction.STREAM_CREATE,
|
||||
generateRandomIP(),
|
||||
rateLimiterMapping
|
||||
)
|
||||
|
||||
expect(isRateLimitBreached(result)).to.be.true
|
||||
expect(result.action).to.equal(RateLimitAction.STREAM_CREATE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActionForPath', () => {
|
||||
it('should rate limit unknown path as all request action', async () => {
|
||||
expect(getActionForPath('/graphql', 'POST')).to.equal(
|
||||
RateLimitAction['POST /graphql']
|
||||
)
|
||||
expect(getActionForPath('/graphql', 'PATCH')).to.equal(
|
||||
RateLimitAction.ALL_REQUESTS
|
||||
)
|
||||
expect(getActionForPath('/foobar', 'GET')).to.equal(RateLimitAction.ALL_REQUESTS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendRateLimitResponse', () => {
|
||||
it('should return 429 and set appropriate headers', async () => {
|
||||
const breached: RateLimitBreached = {
|
||||
isWithinLimits: false,
|
||||
action: RateLimitAction['POST /graphql'],
|
||||
msBeforeNext: 4900
|
||||
}
|
||||
const response = httpMocks.createResponse()
|
||||
await sendRateLimitResponse(response, breached)
|
||||
assert429response(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rateLimiterMiddleware', () => {
|
||||
it('should set header with remaining points if not rate limited', async () => {
|
||||
const request = httpMocks.createRequest({
|
||||
path: '/graphql',
|
||||
method: 'POST'
|
||||
})
|
||||
const response = httpMocks.createResponse()
|
||||
let nextCalled = 0
|
||||
const next = () => {
|
||||
nextCalled++
|
||||
}
|
||||
|
||||
const action = 'POST /graphql'
|
||||
const testMappings = createTestRateLimiterMappings()
|
||||
const limit = 100
|
||||
testMappings[action] = createConsumer(
|
||||
RateLimitAction[action],
|
||||
new RateLimiterMemory({
|
||||
keyPrefix: action,
|
||||
points: limit,
|
||||
duration: 1 * TIME.week
|
||||
})
|
||||
)
|
||||
|
||||
const SUT = createRateLimiterMiddleware(testMappings)
|
||||
|
||||
await temporarilyDisableTestEnv(async () => {
|
||||
await SUT(request, response, next)
|
||||
})
|
||||
|
||||
expect(nextCalled).to.equal(1)
|
||||
expect(response.getHeader('X-RateLimit-Remaining')).to.equal(limit - 1)
|
||||
})
|
||||
|
||||
it('should return 429 if rate limited', async () => {
|
||||
const request = httpMocks.createRequest({
|
||||
path: '/graphql',
|
||||
method: 'POST',
|
||||
ip: generateRandomIP()
|
||||
})
|
||||
|
||||
let response = httpMocks.createResponse()
|
||||
let nextCalled = 0
|
||||
const next = () => {
|
||||
nextCalled++
|
||||
}
|
||||
|
||||
const SUT = createRateLimiterMiddleware(createTestRateLimiterMappings())
|
||||
response = httpMocks.createResponse()
|
||||
|
||||
await temporarilyDisableTestEnv(async () => {
|
||||
await SUT(request, response, next)
|
||||
})
|
||||
|
||||
expect(nextCalled).to.equal(0)
|
||||
assert429response(response)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const temporarilyDisableTestEnv = async (callback: () => Promise<any>) => {
|
||||
const oldNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'temporarily-disabled-test'
|
||||
await callback()
|
||||
process.env.NODE_ENV = oldNodeEnv
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const assert429response = (response: any) => {
|
||||
expect(response.getHeader('X-RateLimit-Remaining')).to.be.undefined
|
||||
expect(response.getHeader('Retry-After')).to.be.greaterThanOrEqual(4)
|
||||
expect(response.getHeader('X-RateLimit-Reset')).to.not.be.undefined
|
||||
expect(response.statusCode).to.equal(429)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { times, clamp } from 'lodash'
|
||||
import { createInviteDirectly } from '@/test/speckle-helpers/inviteHelper'
|
||||
import { getAdminUsersList } from '@/test/graphql/users'
|
||||
import { buildApolloServer } from '@/app'
|
||||
import { addLoadersToCtx } from '@/modules/shared'
|
||||
import { addLoadersToCtx } from '@/modules/shared/middleware'
|
||||
import { Roles, AllScopes } from '@/modules/core/helpers/mainConstants'
|
||||
import { expect } from 'chai'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
'use strict'
|
||||
|
||||
const debug = require('debug')
|
||||
const { contextMiddleware } = require('@/modules/shared')
|
||||
const { saveUploadFile } = require('./services/fileuploads')
|
||||
const request = require('request')
|
||||
const {
|
||||
authMiddlewareCreator,
|
||||
streamWritePermissions
|
||||
} = require('@/modules/shared/authz')
|
||||
const { streamWritePermissions } = require('@/modules/shared/authz')
|
||||
const { authMiddlewareCreator } = require('@/modules/shared/middleware')
|
||||
|
||||
const saveFileUploads = async ({ userId, streamId, branchName, uploadResults }) => {
|
||||
await Promise.all(
|
||||
@@ -36,7 +33,6 @@ exports.init = async (app) => {
|
||||
|
||||
app.post(
|
||||
'/api/file/:fileType/:streamId/:branchName?',
|
||||
contextMiddleware,
|
||||
authMiddlewareCreator(streamWritePermissions),
|
||||
async (req, res) => {
|
||||
req.pipe(
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
const debug = require('debug')
|
||||
|
||||
const {
|
||||
contextMiddleware,
|
||||
validateScopes,
|
||||
authorizeResolver
|
||||
} = require('@/modules/shared')
|
||||
const { validateScopes, authorizeResolver } = require('@/modules/shared')
|
||||
|
||||
const { getStream } = require('../core/services/streams')
|
||||
const { getObject } = require('../core/services/objects')
|
||||
@@ -158,7 +154,7 @@ exports.init = (app) => {
|
||||
return { hasPermissions: true, httpErrorCode: 200 }
|
||||
}
|
||||
|
||||
app.get('/preview/:streamId/:angle?', contextMiddleware, async (req, res) => {
|
||||
app.get('/preview/:streamId/:angle?', async (req, res) => {
|
||||
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
@@ -184,87 +180,75 @@ exports.init = (app) => {
|
||||
)
|
||||
})
|
||||
|
||||
app.get(
|
||||
'/preview/:streamId/branches/:branchName/:angle?',
|
||||
contextMiddleware,
|
||||
async (req, res) => {
|
||||
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile(httpErrorImage(httpErrorCode))
|
||||
}
|
||||
|
||||
let commitsObj
|
||||
try {
|
||||
commitsObj = await getCommitsByBranchName({
|
||||
streamId: req.params.streamId,
|
||||
branchName: req.params.branchName,
|
||||
limit: 1
|
||||
})
|
||||
} catch {
|
||||
commitsObj = {}
|
||||
}
|
||||
const { commits } = commitsObj
|
||||
if (!commits || commits.length === 0) {
|
||||
return res.sendFile(noPreviewImage)
|
||||
}
|
||||
const lastCommit = commits[0]
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
lastCommit.referencedObject,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
app.get('/preview/:streamId/branches/:branchName/:angle?', async (req, res) => {
|
||||
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile(httpErrorImage(httpErrorCode))
|
||||
}
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/preview/:streamId/commits/:commitId/:angle?',
|
||||
contextMiddleware,
|
||||
async (req, res) => {
|
||||
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile(httpErrorImage(httpErrorCode))
|
||||
}
|
||||
|
||||
const commit = await getCommitById({
|
||||
let commitsObj
|
||||
try {
|
||||
commitsObj = await getCommitsByBranchName({
|
||||
streamId: req.params.streamId,
|
||||
id: req.params.commitId
|
||||
branchName: req.params.branchName,
|
||||
limit: 1
|
||||
})
|
||||
if (!commit) return res.sendFile(noPreviewImage)
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
commit.referencedObject,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
} catch {
|
||||
commitsObj = {}
|
||||
}
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/preview/:streamId/objects/:objectId/:angle?',
|
||||
contextMiddleware,
|
||||
async (req, res) => {
|
||||
const { hasPermissions } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile()
|
||||
}
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
req.params.objectId,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
const { commits } = commitsObj
|
||||
if (!commits || commits.length === 0) {
|
||||
return res.sendFile(noPreviewImage)
|
||||
}
|
||||
)
|
||||
const lastCommit = commits[0]
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
lastCommit.referencedObject,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/preview/:streamId/commits/:commitId/:angle?', async (req, res) => {
|
||||
const { hasPermissions, httpErrorCode } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile(httpErrorImage(httpErrorCode))
|
||||
}
|
||||
|
||||
const commit = await getCommitById({
|
||||
streamId: req.params.streamId,
|
||||
id: req.params.commitId
|
||||
})
|
||||
if (!commit) return res.sendFile(noPreviewImage)
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
commit.referencedObject,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/preview/:streamId/objects/:objectId/:angle?', async (req, res) => {
|
||||
const { hasPermissions } = await checkStreamPermissions(req)
|
||||
if (!hasPermissions) {
|
||||
// return res.status( httpErrorCode ).end()
|
||||
return res.sendFile()
|
||||
}
|
||||
|
||||
return sendObjectPreview(
|
||||
req,
|
||||
res,
|
||||
req.params.streamId,
|
||||
req.params.objectId,
|
||||
req.params.angle || DEFAULT_ANGLE
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
exports.finalize = () => {}
|
||||
|
||||
@@ -3,7 +3,7 @@ const { buildApolloServer } = require('@/app')
|
||||
const { Streams, Users, ServerInvites } = require('@/modules/core/dbSchema')
|
||||
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { createUser } = require('@/modules/core/services/users')
|
||||
const { addLoadersToCtx } = require('@/modules/shared')
|
||||
const { addLoadersToCtx } = require('@/modules/shared/middleware')
|
||||
const {
|
||||
createServerInvite,
|
||||
createStreamInvite,
|
||||
@@ -163,6 +163,7 @@ describe('[Stream & Server Invites]', () => {
|
||||
expect(result.errors).to.be.not.ok
|
||||
|
||||
// Check that email was sent out
|
||||
expect(sendEmailInvocations.args).to.have.lengthOf(1)
|
||||
const emailParams = sendEmailInvocations.args[0][0]
|
||||
expect(emailParams).to.be.ok
|
||||
expect(emailParams.to).to.eq(targetEmail)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Express from 'express'
|
||||
import {
|
||||
Scopes,
|
||||
Roles,
|
||||
@@ -40,9 +39,10 @@ export interface AuthContext {
|
||||
token?: string
|
||||
scopes?: string[]
|
||||
stream?: Stream
|
||||
err?: Error | BaseError
|
||||
}
|
||||
|
||||
interface AuthParams {
|
||||
export interface AuthParams {
|
||||
streamId?: string
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ interface RoleData<T extends AvailableRoles> {
|
||||
name: T
|
||||
}
|
||||
|
||||
type AuthPipelineFunction = ({
|
||||
export type AuthPipelineFunction = ({
|
||||
context,
|
||||
authResult,
|
||||
params
|
||||
}: AuthData) => Promise<AuthData>
|
||||
|
||||
const authHasFailed = (authResult: AuthResult): authResult is AuthFailedResult =>
|
||||
export const authHasFailed = (authResult: AuthResult): authResult is AuthFailedResult =>
|
||||
'error' in authResult
|
||||
|
||||
interface RoleValidationInput<T extends AvailableRoles> {
|
||||
@@ -257,41 +257,6 @@ export const authPipelineCreator = (
|
||||
return pipeline
|
||||
}
|
||||
|
||||
interface RequestWithContext extends Express.Request {
|
||||
context: AuthContext
|
||||
}
|
||||
|
||||
//we could even add an auth middleware creator
|
||||
// todo move this to a webserver related module, it has no place here
|
||||
export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => {
|
||||
const pipeline = authPipelineCreator(steps)
|
||||
|
||||
const middleware = async (
|
||||
req: RequestWithContext,
|
||||
res: Express.Response,
|
||||
next: Express.NextFunction
|
||||
) => {
|
||||
const { authResult } = await pipeline({
|
||||
context: req.context as AuthContext,
|
||||
params: req.params as AuthParams,
|
||||
authResult: { authorized: false }
|
||||
})
|
||||
if (!authResult.authorized) {
|
||||
let message = 'Unknown AuthZ error'
|
||||
let status = 500
|
||||
if (authHasFailed(authResult)) {
|
||||
message = authResult.error?.message || message
|
||||
if (authResult.error instanceof UnauthorizedError) status = 401
|
||||
if (authResult.error instanceof ForbiddenError) status = 403
|
||||
}
|
||||
|
||||
return res.status(status).json({ error: message })
|
||||
}
|
||||
next()
|
||||
}
|
||||
return middleware
|
||||
}
|
||||
|
||||
export const streamWritePermissions = [
|
||||
validateServerRole({ requiredRole: Roles.Server.User }),
|
||||
validateScope({ requiredScope: Scopes.Streams.Write }),
|
||||
|
||||
@@ -26,7 +26,11 @@ export function getApolloServerVersion() {
|
||||
}
|
||||
|
||||
export function getFileSizeLimitMB() {
|
||||
return parseInt(process.env.FILE_SIZE_LIMIT_MB || '100')
|
||||
return getIntFromEnv('FILE_SIZE_LIMIT_MB', '100')
|
||||
}
|
||||
|
||||
export function getIntFromEnv(envVarKey: string, aDefault = '0'): number {
|
||||
return parseInt(process.env[envVarKey] || aDefault)
|
||||
}
|
||||
|
||||
export function getRedisUrl() {
|
||||
|
||||
@@ -3,9 +3,6 @@ const Redis = require('ioredis')
|
||||
const knex = require(`@/db/knex`)
|
||||
const { ForbiddenError, ApolloError } = require('apollo-server-express')
|
||||
const { RedisPubSub } = require('graphql-redis-subscriptions')
|
||||
const { buildRequestLoaders } = require('@/modules/core/loaders')
|
||||
const { validateToken } = require(`@/modules/core/services/tokens`)
|
||||
const { getIpFromRequest } = require('@/modules/shared/utils/ip')
|
||||
|
||||
const StreamPubsubEvents = Object.freeze({
|
||||
UserStreamAdded: 'USER_STREAM_ADDED',
|
||||
@@ -28,76 +25,6 @@ const pubsub = new RedisPubSub({
|
||||
subscriber: new Redis(process.env.REDIS_URL)
|
||||
})
|
||||
|
||||
/**
|
||||
* @typedef {import('@/modules/shared/helpers/typeHelper').GraphQLContext} GraphQLContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add data loaders to auth ctx
|
||||
* @param {import('@/modules/shared/authz').AuthContext} ctx
|
||||
* @returns {GraphQLContext}
|
||||
*/
|
||||
function addLoadersToCtx(ctx) {
|
||||
const loaders = buildRequestLoaders(ctx)
|
||||
ctx.loaders = loaders
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for GQL operations
|
||||
* @returns {Promise<GraphQLContext>}
|
||||
*/
|
||||
async function buildContext({ req, connection }) {
|
||||
// Parsing auth info
|
||||
const ctx = await contextApiTokenHelper({ req, connection })
|
||||
ctx.ip = getIpFromRequest(req)
|
||||
// Adding request data loaders
|
||||
return addLoadersToCtx(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not just Graphql server context helper: sets req.context to have an auth prop (true/false), userId and server role.
|
||||
* @returns {Promise<import('@/modules/shared/authz').AuthContext>}
|
||||
*/
|
||||
async function contextApiTokenHelper({ req, connection }) {
|
||||
let token = null
|
||||
|
||||
if (connection && connection.context.token) {
|
||||
// Websockets (subscriptions)
|
||||
token = connection.context.token
|
||||
} else if (req && req.headers.authorization) {
|
||||
// Standard http post
|
||||
token = req.headers.authorization
|
||||
}
|
||||
if (token && token.includes('Bearer ')) {
|
||||
token = token.split(' ')[1]
|
||||
}
|
||||
|
||||
if (token === null) return { auth: false }
|
||||
|
||||
try {
|
||||
const { valid, scopes, userId, role } = await validateToken(token)
|
||||
|
||||
if (!valid) {
|
||||
return { auth: false }
|
||||
}
|
||||
|
||||
return { auth: true, userId, role, token, scopes }
|
||||
} catch (e) {
|
||||
// TODO: Think whether perhaps it's better to throw the error
|
||||
return { auth: false, err: e }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware wrapper around the buildContext function. sets req.context to have an auth prop (true/false), userId and server role.
|
||||
*/
|
||||
async function contextMiddleware(req, res, next) {
|
||||
const result = await buildContext({ req, res })
|
||||
req.context = result
|
||||
next()
|
||||
}
|
||||
|
||||
let roles
|
||||
|
||||
const getRoles = async () => {
|
||||
@@ -209,9 +136,6 @@ async function registerOrUpdateRole(role) {
|
||||
module.exports = {
|
||||
registerOrUpdateScope,
|
||||
registerOrUpdateRole,
|
||||
buildContext,
|
||||
addLoadersToCtx,
|
||||
contextMiddleware,
|
||||
validateServerRole,
|
||||
validateScopes,
|
||||
authorizeResolver,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AuthContext,
|
||||
authPipelineCreator,
|
||||
AuthPipelineFunction,
|
||||
AuthParams,
|
||||
authHasFailed
|
||||
} from '@/modules/shared/authz'
|
||||
import { Request, Response, NextFunction, RequestWithAuthContext } from 'express'
|
||||
import { ForbiddenError, UnauthorizedError } from '@/modules/shared/errors'
|
||||
import { ensureError } from '@/modules/shared/helpers/errorHelper'
|
||||
import { validateToken } from '@/modules/core/services/tokens'
|
||||
import { TokenValidationResult } from '@/modules/core/helpers/types'
|
||||
import { buildRequestLoaders } from '@/modules/core/loaders'
|
||||
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
|
||||
|
||||
export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => {
|
||||
const pipeline = authPipelineCreator(steps)
|
||||
|
||||
const middleware = async (
|
||||
req: RequestWithAuthContext,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const { authResult } = await pipeline({
|
||||
context: req.context as AuthContext,
|
||||
params: req.params as AuthParams,
|
||||
authResult: { authorized: false }
|
||||
})
|
||||
if (!authResult.authorized) {
|
||||
let message = 'Unknown AuthZ error'
|
||||
let status = 500
|
||||
if (authHasFailed(authResult)) {
|
||||
message = authResult.error?.message || message
|
||||
if (authResult.error instanceof UnauthorizedError) status = 401
|
||||
if (authResult.error instanceof ForbiddenError) status = 403
|
||||
}
|
||||
return res.status(status).json({ error: message })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
return middleware
|
||||
}
|
||||
|
||||
export const getTokenFromRequest = (req: Request | null | undefined): string | null =>
|
||||
req?.headers?.authorization ?? null
|
||||
|
||||
/**
|
||||
* Create an AuthContext from a raw token value
|
||||
* @param rawToken
|
||||
* @param tokenValidator
|
||||
* @returns The resulting AuthContext object of the token validator
|
||||
*/
|
||||
export async function createAuthContextFromToken(
|
||||
rawToken: string | null,
|
||||
tokenValidator: (
|
||||
tokenString: string
|
||||
) => Promise<TokenValidationResult> = validateToken
|
||||
): Promise<AuthContext> {
|
||||
if (rawToken === null) return { auth: false }
|
||||
let token = rawToken
|
||||
if (token.startsWith('Bearer ')) token = token.split(' ')[1]
|
||||
|
||||
try {
|
||||
const tokenValidationResult = await tokenValidator(token)
|
||||
if (!tokenValidationResult.valid) return { auth: false }
|
||||
|
||||
const { scopes, userId, role } = tokenValidationResult
|
||||
|
||||
return { auth: true, userId, role, token, scopes }
|
||||
} catch (err) {
|
||||
const surelyError = ensureError(err, 'Unknown error during token validation')
|
||||
return { auth: false, err: surelyError }
|
||||
}
|
||||
}
|
||||
|
||||
export async function authContextMiddleware(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const token = getTokenFromRequest(req)
|
||||
const authContext = await createAuthContextFromToken(token)
|
||||
;(req as RequestWithAuthContext).context = authContext
|
||||
next()
|
||||
}
|
||||
|
||||
export function addLoadersToCtx(ctx: AuthContext): GraphQLContext {
|
||||
const loaders = buildRequestLoaders(ctx)
|
||||
return { ...ctx, loaders }
|
||||
}
|
||||
type MaybeAuthenticatedRequest = Request | RequestWithAuthContext | null | undefined
|
||||
const isRequestWithAuthContext = (
|
||||
req: MaybeAuthenticatedRequest
|
||||
): req is RequestWithAuthContext =>
|
||||
req !== null && req !== undefined && 'context' in req
|
||||
/**
|
||||
* Build context for GQL operations
|
||||
*/
|
||||
export async function buildContext({
|
||||
req,
|
||||
token
|
||||
}: {
|
||||
req: MaybeAuthenticatedRequest
|
||||
token: string | null
|
||||
}): Promise<GraphQLContext> {
|
||||
const ctx = isRequestWithAuthContext(req)
|
||||
? req.context
|
||||
: await createAuthContextFromToken(token ?? getTokenFromRequest(req))
|
||||
|
||||
// Adding request data loaders
|
||||
return addLoadersToCtx(ctx)
|
||||
}
|
||||
@@ -77,6 +77,7 @@
|
||||
"pg": "^8.7.3",
|
||||
"pg-query-stream": "^4.2.3",
|
||||
"prom-client": "^14.0.1",
|
||||
"rate-limiter-flexible": "^2.4.1",
|
||||
"redis": "^3.1.1",
|
||||
"request": "^2.88.2",
|
||||
"response-time": "^2.3.2",
|
||||
@@ -133,6 +134,7 @@
|
||||
"mocha": "^10.1.0",
|
||||
"mocha-junit-reporter": "^2.0.2",
|
||||
"mock-require": "^3.0.3",
|
||||
"node-mocks-http": "^1.12.1",
|
||||
"nodemon": "^2.0.6",
|
||||
"nyc": "^15.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
|
||||
@@ -80,6 +80,7 @@ exports.sleep = (ms) => {
|
||||
* @param {*} res
|
||||
*/
|
||||
function noErrors(res) {
|
||||
if (res.error) throw new Error(`Failed GraphQL request: ${res.error.message}`)
|
||||
if ('errors' in res.body)
|
||||
throw new Error(`Failed GraphQL request: ${res.body.errors[0].message}`)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
require('../bootstrap')
|
||||
|
||||
// Register global mocks as early as possible
|
||||
require('@/test/mocks/global')
|
||||
|
||||
const chai = require('chai')
|
||||
const chaiHttp = require('chai-http')
|
||||
const deepEqualInAnyOrder = require('deep-equal-in-any-order')
|
||||
@@ -11,9 +15,6 @@ chai.use(chaiHttp)
|
||||
chai.use(deepEqualInAnyOrder)
|
||||
chai.use(graphqlChaiPlugin)
|
||||
|
||||
// Register global mocks
|
||||
require('@/test/mocks/global')
|
||||
|
||||
const unlock = async () => {
|
||||
const exists = await knex.schema.hasTable('knex_migrations_lock')
|
||||
if (exists) {
|
||||
|
||||
@@ -84,7 +84,7 @@ export function buildNotificationsStateTracker() {
|
||||
/**
|
||||
* Wait for an acknowledgement without knowing the msg id
|
||||
*/
|
||||
waitForAck: async (predicate?: (e: AckEvent) => boolean, timeout = 2000) => {
|
||||
waitForAck: async (predicate?: (e: AckEvent) => boolean, timeout = 3000) => {
|
||||
let timeoutRef: NodeJS.Timer
|
||||
let eventEmitterHandler: (e: AckEvent) => void
|
||||
return new Promise<AckEvent>((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildApolloServer } from '@/app'
|
||||
import { Roles, AllScopes } from '@/modules/core/helpers/mainConstants'
|
||||
import { addLoadersToCtx } from '@/modules/shared'
|
||||
import { addLoadersToCtx } from '@/modules/shared/middleware'
|
||||
|
||||
/**
|
||||
* Build an ApolloServer instance with an authenticated context
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Request } from 'express'
|
||||
import { AuthContext } from '@/modules/shared/authz'
|
||||
|
||||
declare module 'express' {
|
||||
interface RequestWithAuthContext extends Request {
|
||||
context: AuthContext
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Time with seconds as the base unit
|
||||
*/
|
||||
export const TIME = {
|
||||
second: 1,
|
||||
minute: 60,
|
||||
hour: 60 * 60,
|
||||
day: 24 * 60 * 60,
|
||||
week: 7 * 24 * 60 * 60,
|
||||
month: 28 * 24 * 60 * 60
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './constants'
|
||||
export * from './helpers/utilityTypes'
|
||||
export * from './helpers/utility'
|
||||
export * from './helpers/batch'
|
||||
export * from './helpers/timeConstants'
|
||||
export * from './helpers/utility'
|
||||
export * from './helpers/utilityTypes'
|
||||
export * from './utils/localStorage'
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
"files.eol": "\n",
|
||||
"volar.completion.preferredTagNameCase": "kebab",
|
||||
"volar.vueserver.maxOldSpaceSize": 4000,
|
||||
"vscode-graphql.cacheSchemaFileForLookup": true
|
||||
"vscode-graphql.cacheSchemaFileForLookup": true,
|
||||
"cSpell.words": ["Bursty"]
|
||||
},
|
||||
"extensions": {
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
|
||||
@@ -5070,6 +5070,7 @@ __metadata:
|
||||
morgan-debug: ^2.0.0
|
||||
node-cron: ^3.0.2
|
||||
node-machine-id: ^1.1.12
|
||||
node-mocks-http: ^1.12.1
|
||||
nodemailer: ^6.5.0
|
||||
nodemon: ^2.0.6
|
||||
nyc: ^15.0.1
|
||||
@@ -5082,6 +5083,7 @@ __metadata:
|
||||
pg-query-stream: ^4.2.3
|
||||
prettier: ^2.5.1
|
||||
prom-client: ^14.0.1
|
||||
rate-limiter-flexible: ^2.4.1
|
||||
redis: ^3.1.1
|
||||
request: ^2.88.2
|
||||
response-time: ^2.3.2
|
||||
@@ -7086,7 +7088,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"accepts@npm:^1.3.5, accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8":
|
||||
"accepts@npm:^1.3.5, accepts@npm:^1.3.7, accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8":
|
||||
version: 1.3.8
|
||||
resolution: "accepts@npm:1.3.8"
|
||||
dependencies:
|
||||
@@ -9130,7 +9132,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"content-disposition@npm:0.5.4":
|
||||
"content-disposition@npm:0.5.4, content-disposition@npm:^0.5.3":
|
||||
version: 0.5.4
|
||||
resolution: "content-disposition@npm:0.5.4"
|
||||
dependencies:
|
||||
@@ -9905,7 +9907,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"depd@npm:^1.1.2, depd@npm:~1.1.0, depd@npm:~1.1.2":
|
||||
"depd@npm:^1.1.0, depd@npm:^1.1.2, depd@npm:~1.1.0, depd@npm:~1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "depd@npm:1.1.2"
|
||||
checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9
|
||||
@@ -11779,7 +11781,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fresh@npm:0.5.2":
|
||||
"fresh@npm:0.5.2, fresh@npm:^0.5.2":
|
||||
version: 0.5.2
|
||||
resolution: "fresh@npm:0.5.2"
|
||||
checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346
|
||||
@@ -14679,7 +14681,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"merge-descriptors@npm:1.0.1":
|
||||
"merge-descriptors@npm:1.0.1, merge-descriptors@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "merge-descriptors@npm:1.0.1"
|
||||
checksum: 5abc259d2ae25bb06d19ce2b94a21632583c74e2a9109ee1ba7fd147aa7362b380d971e0251069f8b3eb7d48c21ac839e21fa177b335e82c76ec172e30c31a26
|
||||
@@ -14768,7 +14770,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime@npm:1.6.0, mime@npm:^1.4.1, mime@npm:^1.6.0":
|
||||
"mime@npm:1.6.0, mime@npm:^1.3.4, mime@npm:^1.4.1, mime@npm:^1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "mime@npm:1.6.0"
|
||||
bin:
|
||||
@@ -15814,6 +15816,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-mocks-http@npm:^1.12.1":
|
||||
version: 1.12.1
|
||||
resolution: "node-mocks-http@npm:1.12.1"
|
||||
dependencies:
|
||||
accepts: ^1.3.7
|
||||
content-disposition: ^0.5.3
|
||||
depd: ^1.1.0
|
||||
fresh: ^0.5.2
|
||||
merge-descriptors: ^1.0.1
|
||||
methods: ^1.1.2
|
||||
mime: ^1.3.4
|
||||
parseurl: ^1.3.3
|
||||
range-parser: ^1.2.0
|
||||
type-is: ^1.6.18
|
||||
checksum: 80b2ef4967d95e5804f4be5edd13bab06e6cad7c00813e32fd1958189ee8c4c68b833aabcbb2b39194b412b14504a7724e628d74ca5956a83221e0fa838aaf92
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-preload@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "node-preload@npm:0.2.1"
|
||||
@@ -17388,13 +17408,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"range-parser@npm:^1.2.1, range-parser@npm:~1.2.0, range-parser@npm:~1.2.1":
|
||||
"range-parser@npm:^1.2.0, range-parser@npm:^1.2.1, range-parser@npm:~1.2.0, range-parser@npm:~1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "range-parser@npm:1.2.1"
|
||||
checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rate-limiter-flexible@npm:^2.4.1":
|
||||
version: 2.4.1
|
||||
resolution: "rate-limiter-flexible@npm:2.4.1"
|
||||
checksum: 5eea3ffbb6a11f634edd8b9575815c2bf239a8becdfdc82c4183cad92025e853913972a2b2f2d45c16e81aea1a3451fbad8da76dee1ba0e4549c22a0ba58c50f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"raw-body@npm:2.3.3":
|
||||
version: 2.3.3
|
||||
resolution: "raw-body@npm:2.3.3"
|
||||
@@ -19894,7 +19921,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-is@npm:~1.6.16, type-is@npm:~1.6.18":
|
||||
"type-is@npm:^1.6.18, type-is@npm:~1.6.16, type-is@npm:~1.6.18":
|
||||
version: 1.6.18
|
||||
resolution: "type-is@npm:1.6.18"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user