Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 557 lines 18 kB view raw
1/* eslint-disable max-lines */ 2// In this case, we want to rely on apollo-server-express bundling a 3// corresponding version of apollo-server-core, rather than picking an 4// apollo-server-core version in package.json 5// eslint-disable-next-line import/no-extraneous-dependencies 6import os from 'node:os'; 7import path from 'path'; 8import { makeExecutableSchema } from '@graphql-tools/schema'; 9import { MapperKind, mapSchema } from '@graphql-tools/utils'; 10import { SpanStatusCode } from '@opentelemetry/api'; 11import { 12 SEMATTRS_EXCEPTION_MESSAGE, 13 SEMATTRS_EXCEPTION_STACKTRACE, 14 SEMATTRS_EXCEPTION_TYPE, 15} from '@opentelemetry/semantic-conventions'; 16import { 17 ApolloError, 18 ApolloServerPluginLandingPageDisabled, 19 ApolloServerPluginLandingPageGraphQLPlayground, 20} from 'apollo-server-core'; 21import { ApolloServer } from 'apollo-server-express'; 22import connectPgSimple from 'connect-pg-simple'; 23import cors from 'cors'; 24import express, { type ErrorRequestHandler } from 'express'; 25import session from 'express-session'; 26import { buildContext, GraphQLLocalStrategy } from 'graphql-passport'; 27import depthLimit from 'graphql-depth-limit'; 28import helmet from 'helmet'; 29import passport from 'passport'; 30import { MultiSamlStrategy } from '@node-saml/passport-saml'; 31 32import { 33 makeLoginIncorrectPasswordError, 34 makeLoginSsoRequiredError, 35 makeLoginUserDoesNotExistError, 36} from './graphql/datasources/UserApi.js'; 37import resolvers from './graphql/resolvers.js'; 38import typeDefs from './graphql/schema.js'; 39import { authSchemaWrapper } from './graphql/utils/authorization.js'; 40import { type Dependencies } from './iocContainer/index.js'; 41import { safeGetEnvInt } from './iocContainer/utils.js'; 42import controllers from './routes/index.js'; 43import { jsonStringify } from './utils/encoding.js'; 44import { 45 ErrorType, 46 getErrorsFromAggregateError, 47 makeBadRequestError, 48 makeInternalServerError, 49 makeNotFoundError, 50 sanitizeError, 51 type SerializableError, 52} from './utils/errors.js'; 53import { safePick } from './utils/misc.js'; 54import { 55 isNonEmptyArray, 56 type NonEmptyArray, 57} from './utils/typescript-types.js'; 58 59function getCPUInfo() { 60 const cpus = os.cpus(); 61 62 const total = cpus.reduce( 63 (acc, cpu) => 64 acc + 65 cpu.times.user + 66 cpu.times.nice + 67 cpu.times.sys + 68 cpu.times.irq + 69 cpu.times.idle, 70 0, 71 ); 72 const idle = cpus.reduce((acc, cpu) => acc + cpu.times.idle, 0); 73 74 return { 75 idle, 76 total, 77 }; 78} 79 80async function getCPUUsage() { 81 const stats1 = getCPUInfo(); 82 const startIdle = stats1.idle; 83 const startTotal = stats1.total; 84 await new Promise((resolve) => setTimeout(resolve, 1000)); 85 86 const stats2 = getCPUInfo(); 87 const endIdle = stats2.idle; 88 const endTotal = stats2.total; 89 return 1 - (endIdle - startIdle) / (endTotal - startTotal); 90} 91 92// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing 93const env = process.env.NODE_ENV || 'development'; 94const sessionStore = connectPgSimple(session); 95 96export default async function makeApiServer(deps: Dependencies) { 97 const app = express(); 98 const { User } = deps.Sequelize; 99 100 app.use(cors()); 101 102 app.use( 103 helmet( 104 env === 'production' 105 ? {} 106 : { 107 contentSecurityPolicy: { 108 directives: { 109 defaultSrc: ["'self'"], 110 scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 111 styleSrc: ["'self'", "'unsafe-inline'"], 112 imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'http:'], 113 connectSrc: ["'self'", 'ws:', 'wss:', 'https:', 'http:'], 114 fontSrc: ["'self'", 'data:', 'https:'], 115 frameSrc: ["'self'"], 116 }, 117 }, 118 }, 119 ), 120 ); 121 app.use(express.json({ limit: '50mb' })); 122 123 app.get('/ready', async (_req, res) => { 124 const cpuUsage = await getCPUUsage(); 125 if (cpuUsage > 0.75) { 126 return res.status(500).send('Unhealthy'); 127 } 128 return res.status(200).send('Healthy'); 129 }); 130 131 /** 132 * Passport & User Session Configuration 133 */ 134 const { 135 DATABASE_HOST, 136 DATABASE_PORT = 5432, 137 DATABASE_NAME, 138 DATABASE_USER, 139 DATABASE_PASSWORD, 140 } = process.env; 141 142 const connectionString = `postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}`; 143 144 app.use( 145 session({ 146 secret: process.env.SESSION_SECRET!, 147 store: new sessionStore({ conString: connectionString }), 148 cookie: { 149 secure: process.env.NODE_ENV === 'production', 150 httpOnly: true, 151 sameSite: 'lax', 152 // 30 Days in milliseconds 153 maxAge: 30 * 24 * 60 * 60 * 1000, 154 }, 155 resave: false, 156 saveUninitialized: false, 157 proxy: true, 158 }), 159 ); 160 app.use(passport.initialize()); 161 app.use(passport.session()); 162 163 passport.use( 164 new MultiSamlStrategy( 165 { 166 passReqToCallback: true, 167 async getSamlOptions(req, done) { 168 // orgId path param should be set in the /saml/* route handlers 169 const orgId = req.params['orgId']; 170 171 if (!orgId) { 172 return done( 173 makeNotFoundError('orgId not found in path.', { 174 shouldErrorSpan: true, 175 }), 176 ); 177 } 178 179 const samlSettings = await deps.OrgSettingsService.getSamlSettings( 180 orgId, 181 ); 182 183 if (!samlSettings) 184 return done( 185 makeInternalServerError('Unexpected error.', { 186 shouldErrorSpan: true, 187 }), 188 ); 189 190 if (!samlSettings.saml_enabled) 191 return done( 192 makeBadRequestError('SAML not enabled for this organization.', { 193 shouldErrorSpan: true, 194 }), 195 ); 196 197 done(null, { 198 entryPoint: samlSettings.sso_url as string, 199 idpCert: samlSettings.cert as string, 200 // I could use UI_URL here but technically the API could be hosted 201 // on a different domain in the future so hopefully this is more 202 // robust, not that it will likely matter. 203 callbackUrl: `${deps.ConfigService.uiUrl}/api/v1/saml/login/${orgId}/callback`, 204 issuer: deps.ConfigService.uiUrl, 205 }); 206 }, 207 }, 208 async (_req, profile, done) => { 209 try { 210 const user = await User.findOne({ 211 where: { email: String(profile?.email) }, 212 }); 213 // we should have already checked for this, but couldn't hurt to check 214 // again 215 if (user == null) { 216 return done( 217 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 218 ); 219 } 220 221 return done(null, user as any); 222 } catch (e) { 223 return done( 224 makeInternalServerError('Unknown error during login attempt', { 225 shouldErrorSpan: true, 226 }), 227 ); 228 } 229 }, 230 async (_req, profile, done) => { 231 try { 232 const user = await User.findOne({ 233 where: { email: String(profile?.email) }, 234 }); 235 // we should have already checked for this, but couldn't hurt to check 236 // again 237 if (user == null) { 238 return done( 239 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 240 ); 241 } 242 243 return done(null, user as any); 244 } catch (e) { 245 return done( 246 makeInternalServerError('Unknown error during login attempt', { 247 shouldErrorSpan: true, 248 }), 249 ); 250 } 251 }, 252 ), 253 ); 254 255 app.get( 256 '/saml/login/:orgId', 257 passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), 258 ); 259 260 app.post( 261 `/saml/login/:orgId/callback`, 262 express.urlencoded({ extended: false }), 263 passport.authenticate('saml', { 264 failureRedirect: '/', 265 failureFlash: true, 266 }), 267 (_req, res) => { 268 res.redirect(`${deps.ConfigService.uiUrl}/dashboard`); 269 }, 270 ); 271 272 passport.use( 273 new GraphQLLocalStrategy(async (email, password, done) => { 274 try { 275 const user = await User.findOne({ where: { email: String(email) } }); 276 if (user == null) { 277 return done( 278 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 279 ); 280 } 281 const samlSettings = await deps.OrgSettingsService.getSamlSettings( 282 user.orgId, 283 ); 284 285 if ( 286 samlSettings?.saml_enabled && 287 // We allow Coop users to log in with email/password even if SSO is 288 // enabled 289 // so Coop employees can manage user accounts 290 String(email).split('@')[1] !== 'getcoop.com' 291 ) { 292 return done( 293 makeLoginSsoRequiredError({ 294 detail: 295 'SAML is enabled for this organization. Password login is disabled.', 296 shouldErrorSpan: true, 297 }), 298 ); 299 } 300 301 if (!user.loginMethods.includes('password')) { 302 return done( 303 makeLoginIncorrectPasswordError({ 304 detail: 'Password is not set for user.', 305 shouldErrorSpan: true, 306 }), 307 ); 308 } 309 310 // if loginMethod is password, password should be set 311 if ( 312 await User.passwordMatchesHash( 313 String(password), 314 user.password satisfies string | null as string, 315 ) 316 ) { 317 done(null, user); 318 } else { 319 done(makeLoginIncorrectPasswordError({ shouldErrorSpan: true })); 320 } 321 } catch (e) { 322 deps.Tracer.logActiveSpanFailedIfAny(e); 323 return done( 324 makeInternalServerError('Unknown error during login attempt', { 325 shouldErrorSpan: true, 326 }), 327 ); 328 } 329 }), 330 ); 331 332 passport.serializeUser((user: any, done) => { 333 done(null, user.id); 334 }); 335 336 passport.deserializeUser(async (id, done) => { 337 return User.findByPk(String(id), { rejectOnEmpty: true }).then((user) => { 338 done(null, user); 339 }, done); 340 }); 341 342 /** 343 * Apollo Server - uses /api/graphql path 344 */ 345 const apolloServer = new ApolloServer({ 346 schema: mapSchema(makeExecutableSchema({ typeDefs, resolvers }), { 347 [MapperKind.QUERY_ROOT_FIELD]( 348 fieldConfig, 349 _fieldName, 350 _typeName, 351 schema, 352 ) { 353 return authSchemaWrapper(fieldConfig, schema); 354 }, 355 [MapperKind.MUTATION_ROOT_FIELD]( 356 fieldConfig, 357 _fieldName, 358 _typeName, 359 schema, 360 ) { 361 return authSchemaWrapper(fieldConfig, schema); 362 }, 363 }), 364 dataSources: () => deps.DataSources, 365 context: ({ req, res }) => { 366 return { 367 ...buildContext({ req, res }), 368 services: makeGqlServices(deps), 369 }; 370 }, 371 plugins: [ 372 { 373 ...(process.env.NODE_ENV === 'production' 374 ? ApolloServerPluginLandingPageDisabled() 375 : ApolloServerPluginLandingPageGraphQLPlayground()), 376 }, 377 ], 378 validationRules: [depthLimit(safeGetEnvInt('GRAPHQL_MAX_DEPTH', 10))], 379 introspection: process.env.NODE_ENV !== 'production', 380 formatError(e) { 381 // `e` can be an ApolloError instance, but will only be one if such an 382 // instance (or an ApolloError subclass) was explicitly thrown from a 383 // resolver. In that case, we assume the thrower knows they're dealing 384 // with apollo, and we can just pass the error through as-is. 385 if (e instanceof ApolloError) { 386 return e; 387 } 388 389 // In almost all other cases, the error will be an instance of the 390 // `GraphQLError` class, which apollo instantiates automatically, and uses 391 // to wrap any non-ApolloError error thrown from a resolver. However, 392 // ocassionally -- e.g., if an error occurs during context creation rather 393 // than in the resolver -- the error doesn't get wrapped (or it's wrapped 394 // but with no originalError), so we handle both cases. Once we have the 395 // underlying error that was actually thrown, we sanitize it to remove 396 // sensitive details, and then try to format it in the most informative 397 // way possible. 398 const sanitizedError = sanitizeError(e.originalError ?? e); 399 const { title: sanitizedErrorTitle, ...extensions } = sanitizedError; 400 401 return { 402 // When apollo-server wraps the resolver-thrown error in a GraphQLError, 403 // it automatically tracks some metadata about where the error was thrown 404 // from. That can be useful to clients, in a way that's a bit different 405 // from our CoopError.pointer field; it tells them whether a null 406 // value was return in the response because a given resolver failed, or 407 // because the field's value is actually null. So, we pass this 408 // apollo-annotated metdata through as-is. 409 locations: e.locations, 410 path: e.path, 411 // Apollo server also defines some predefined error codes that it could 412 // be helpful for us to mimic on our custom errors (in case Apollo 413 // clients handle them out of the box). The true, Coop-assigned code 414 // for the error, though, will be in the `type` key, just like when 415 // sending errors in REST responses (though, for GQL, this lives under 416 // `extensions`). 417 code: extensions.type.includes(ErrorType.Unauthenticated) 418 ? 'UNAUTHENTICATED' 419 : extensions.type.includes(ErrorType.Unauthorized) 420 ? 'FORBIDDEN' 421 : extensions.type.includes(ErrorType.InvalidUserInput) 422 ? 'BAD_USER_INPUT' 423 : 'INTERNAL_SERVER_ERROR', 424 // Then, this is info from the sanitized verion of the actual thrown error. 425 message: sanitizedErrorTitle, 426 extensions, 427 }; 428 }, 429 }); 430 431 await apolloServer.start().then(() => { 432 apolloServer.applyMiddleware({ app }); 433 Object.entries(controllers).forEach(([_k, controller]) => { 434 controller.routes.forEach((it) => { 435 const handler = it.handler(deps); 436 app[it.method]( 437 path.join(controller.pathPrefix, it.path), 438 ...(Array.isArray(handler) ? handler : [handler]), 439 ); 440 }); 441 }); 442 443 // catch 404 and forward to error handler 444 app.use(function (_req, _res, next) { 445 next( 446 makeNotFoundError('Requested route not found.', { 447 shouldErrorSpan: true, 448 }), 449 ); 450 }); 451 452 // error handler 453 app.use(async function (err, _req, res, _next) { 454 await deps.Tracer.addActiveSpan( 455 { resource: 'app', operation: 'handleError' }, 456 async (span) => { 457 span.recordException(err); 458 span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); 459 460 // I don't know if these attributes are necessary, with recordException 461 span.setAttribute(SEMATTRS_EXCEPTION_MESSAGE, err.message); 462 if (err.stack) { 463 span.setAttribute(SEMATTRS_EXCEPTION_STACKTRACE, err.stack); 464 } 465 span.setAttribute(SEMATTRS_EXCEPTION_TYPE, err.name); 466 467 const errors = (() => { 468 if (err instanceof AggregateError) { 469 const extractedErrors = getErrorsFromAggregateError(err); 470 return isNonEmptyArray(extractedErrors) ? extractedErrors : [err]; 471 } else { 472 return [err]; 473 } 474 })() satisfies NonEmptyArray<unknown>; 475 476 // If we had any nested errors (from an AggregateError), 477 // attach those to the span too. 478 if (errors.length > 1 || errors[0] !== err) { 479 span.setAttribute( 480 'errors', 481 jsonStringify( 482 errors.map((it) => safePick(it, ['name', 'message', 'stack'])), 483 ), 484 ); 485 } 486 487 // If we've already sent response headers or the response status code, 488 // we can't actually send a different status code here: it's an error 489 // in HTTP to send the headers portion of a response twice. So, we 490 // need to skip this step. 491 // 492 // This can happen, e.g., if we have a request handler that 493 // immediately responds with a 202/204 but then continues to do some 494 // processing work in the background, and that work errors. 495 if (!res.headersSent) { 496 const safeErrors = errors.map((it) => 497 sanitizeError(it), 498 ) satisfies SerializableError[] as NonEmptyArray<SerializableError>; 499 500 res.status(pickStatus(safeErrors)).json({ errors: safeErrors }); 501 } 502 }, 503 ); 504 } as ErrorRequestHandler); 505 }); 506 507 return { 508 app, 509 async shutdown() { 510 await Promise.all([ 511 apolloServer.stop(), 512 deps.closeSharedResourcesForShutdown(), 513 ]); 514 }, 515 }; 516} 517 518function pickStatus(safeErrors: NonEmptyArray<SerializableError>) { 519 return safeErrors[0].status; 520} 521 522function makeGqlServices(deps: Dependencies) { 523 return { 524 ...safePick(deps, [ 525 'ApiKeyService', 526 'DataWarehouse', 527 'DerivedFieldsService', 528 'getItemTypeEventuallyConsistent', 529 'getEnabledRulesForItemTypeEventuallyConsistent', 530 'ItemInvestigationService', 531 'ModerationConfigService', 532 'ManualReviewToolService', 533 'HMAHashBankService', 534 'NcmecService', 535 'OrgSettingsService', 536 'PartialItemsService', 537 'ReportingService', 538 'RuleEvaluator', 539 'Sequelize', 540 'SignalsService', 541 'SigningKeyPairService', 542 'Tracer', 543 'UserManagementService', 544 'UserStatisticsService', 545 'UserHistoryQueries', 546 'UserStrikeService', 547 'SSOService', 548 ]), 549 // Calling sendEmail straight from a resolver is hella sketch, as the 550 // resolvers shouldn’t have real business logic in them. Future sendEmail 551 // calls should be encapsulated inside some business-logic-containing 552 // service, and it’s that service that should be called from the resolvers. 553 legacy_DO_NOT_USE_DIRECTLY_sendEmail: deps.sendEmail, 554 }; 555} 556 557export type GQLServices = ReturnType<typeof makeGqlServices>;