Something went wrong. Try again.
Mirrored from GitHub github.com/roostorg/coop
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468import os from 'node:os';import path from 'path';import { ApolloServer } from '@apollo/server';import { unwrapResolverError } from '@apollo/server/errors';import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';import { expressMiddleware } from '@as-integrations/express5';import { makeExecutableSchema } from '@graphql-tools/schema';import { MapperKind, mapSchema } from '@graphql-tools/utils';import { MultiSamlStrategy, type Profile, type VerifiedCallback,} from '@node-saml/passport-saml';import { SpanStatusCode } from '@opentelemetry/api';import { ATTR_EXCEPTION_MESSAGE, ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE,} from '@opentelemetry/semantic-conventions';import connectPgSimple from 'connect-pg-simple';import cors from 'cors';import express, { type ErrorRequestHandler, type Request } from 'express';import session from 'express-session';import { GraphQLError, type GraphQLFormattedError } from 'graphql';import helmet from 'helmet';import passport from 'passport';
import { kyselyUserFindById } from './graphql/datasources/userKyselyPersistence.js';import resolvers, { type Context } from './graphql/resolvers.js';import typeDefs from './graphql/schema.js';import { authSchemaWrapper } from './graphql/utils/authorization.js';import { getOrgIdFromPath } from './graphql/utils/orgIdFromPath.js';import { buildPassportContext } from './graphql/utils/passportContext.js';import { resolveSamlUser } from './graphql/utils/resolveSamlUser.js';import { safeDepthLimit } from './graphql/utils/safeDepthLimit.js';import { type Dependencies } from './iocContainer/index.js';import { safeGetEnvInt } from './iocContainer/utils.js';import controllers from './routes/index.js';import { createBodySchemaValidator } from './utils/bodySchemaValidation.js';import { jsonStringify } from './utils/encoding.js';import { ErrorType, getErrorsFromAggregateError, makeBadRequestError, makeInternalServerError, makeNotFoundError, sanitizeError, type SerializableError,} from './utils/errors.js';import { safePick } from './utils/misc.js';import { isNonEmptyArray, type NonEmptyArray,} from './utils/typescript-types.js';
function getCPUInfo() { const cpus = os.cpus();
const total = cpus.reduce( (acc, cpu) => acc + cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle, 0, ); const idle = cpus.reduce((acc, cpu) => acc + cpu.times.idle, 0);
return { idle, total, };}
async function getCPUUsage() { const stats1 = getCPUInfo(); const startIdle = stats1.idle; const startTotal = stats1.total; await new Promise((resolve) => setTimeout(resolve, 1000));
const stats2 = getCPUInfo(); const endIdle = stats2.idle; const endTotal = stats2.total; return 1 - (endIdle - startIdle) / (endTotal - startTotal);}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescingconst env = process.env.NODE_ENV || 'development';const sessionStore = connectPgSimple(session);
export default async function makeApiServer(deps: Dependencies) { const app = express(); const { KyselyPg, KyselyPgPool } = deps;
app.use(cors());
app.use( helmet( env === 'production' ? {} : { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'http:'], connectSrc: ["'self'", 'ws:', 'wss:', 'https:', 'http:'], fontSrc: ["'self'", 'data:', 'https:'], frameSrc: ["'self'"], }, }, }, ), ); app.use(express.json({ limit: '50mb' }));
app.get('/ready', async (_req, res) => { const cpuUsage = await getCPUUsage(); if (cpuUsage > 0.75) { return res.status(500).send('Unhealthy'); } return res.status(200).send('Healthy'); });
/** * Passport & User Session Configuration */ const sessionStoreInstance = new sessionStore({ pool: KyselyPgPool }); app.use( session({ secret: process.env.SESSION_SECRET!, store: sessionStoreInstance, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'lax', // 30 Days in milliseconds maxAge: 30 * 24 * 60 * 60 * 1000, }, resave: false, saveUninitialized: false, proxy: true, }), ); app.use(passport.initialize()); app.use(passport.session());
// Shared signon/logout verify: bind the user lookup to the org named in the // callback path so an assertion authenticating one org can never resolve a // user from another (GHSA-2v93-383c-9fw2). const verify = async ( req: Request, profile: Profile | null, done: VerifiedCallback, ) => resolveSamlUser(KyselyPg, deps.Tracer, req, profile, done);
passport.use( new MultiSamlStrategy( { passReqToCallback: true, async getSamlOptions(req, done) { // orgId path param should be set in the /saml/* route handlers. const orgId = getOrgIdFromPath(req);
if (!orgId) { return done( makeNotFoundError('orgId not found in path.', { shouldErrorSpan: true, }), ); }
const samlSettings = await deps.OrgSettingsService.getSamlSettings(orgId);
if (!samlSettings) return done( makeInternalServerError('Unexpected error.', { shouldErrorSpan: true, }), );
if (!samlSettings.saml_enabled) return done( makeBadRequestError('SAML not enabled for this organization.', { shouldErrorSpan: true, }), );
done(null, { entryPoint: samlSettings.sso_url as string, idpCert: samlSettings.cert as string, // I could use UI_URL here but technically the API could be hosted // on a different domain in the future so hopefully this is more // robust, not that it will likely matter. callbackUrl: `${deps.ConfigService.uiUrl}/api/v1/saml/login/${orgId}/callback`, issuer: deps.ConfigService.uiUrl, }); }, }, verify, verify, ), );
app.get( '/saml/login/:orgId', passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), );
app.post( `/saml/login/:orgId/callback`, express.urlencoded(), passport.authenticate('saml', { failureRedirect: '/', failureFlash: true, }), (_req, res) => { res.redirect(`${deps.ConfigService.uiUrl}/dashboard`); }, );
passport.serializeUser((user, done) => { done(null, user.id); });
passport.deserializeUser(async (id, done) => { try { const user = await kyselyUserFindById(KyselyPg, String(id)); if (user == null) { return done( makeNotFoundError(`Session user ${String(id)} not found`, { shouldErrorSpan: true, }), ); } return done(null, user); } catch (e) { return done(e); } });
/** * Apollo Server - uses /api/graphql path */ const apolloServer = new ApolloServer<Context>({ schema: mapSchema(makeExecutableSchema({ typeDefs, resolvers }), { [MapperKind.QUERY_ROOT_FIELD]( fieldConfig, _fieldName, _typeName, schema, ) { return authSchemaWrapper(fieldConfig, schema); }, [MapperKind.MUTATION_ROOT_FIELD]( fieldConfig, _fieldName, _typeName, schema, ) { return authSchemaWrapper(fieldConfig, schema); }, }), plugins: [ ...(process.env.NODE_ENV === 'production' ? [ApolloServerPluginLandingPageDisabled()] : []), ], validationRules: [safeDepthLimit(safeGetEnvInt('GRAPHQL_MAX_DEPTH', 10))], introspection: process.env.NODE_ENV !== 'production', formatError(formattedError, error) { // unwrapResolverError removes the GraphQLError wrapper added by graphql-js // when a non-GraphQL error is thrown from a resolver. const rawError = unwrapResolverError(error);
// If the raw error is a GraphQLError (explicitly thrown by our code or // generated by graphql-js for parse/validation errors), the formattedError // is already correctly shaped -- pass it through. if (rawError instanceof GraphQLError) { return formattedError; }
// For all other errors (CoopError, unexpected errors, context errors), // sanitize to remove sensitive details and reformat for the client. const sanitizedError = sanitizeError( rawError instanceof Error ? rawError : error, ); const { title: sanitizedErrorTitle, ...extensions } = sanitizedError;
const result: GraphQLFormattedError = { // When graphql-js wraps the resolver-thrown error in a GraphQLError, // it automatically tracks some metadata about where the error was thrown // from. That can be useful to clients, in a way that's a bit different // from our CoopError.pointer field; it tells them whether a null // value was return in the response because a given resolver failed, or // because the field's value is actually null. So, we pass this // metadata through as-is. locations: formattedError.locations, path: formattedError.path, // Apollo server also defines some predefined error codes that it could // be helpful for us to mimic on our custom errors (in case Apollo // clients handle them out of the box). The true, Coop-assigned code // for the error, though, will be in the `type` key, just like when // sending errors in REST responses (though, for GQL, this lives under // `extensions`). extensions: { ...extensions, code: extensions.type.includes(ErrorType.Unauthenticated) ? 'UNAUTHENTICATED' : extensions.type.includes(ErrorType.Unauthorized) ? 'FORBIDDEN' : extensions.type.includes(ErrorType.InvalidUserInput) ? 'BAD_USER_INPUT' : 'INTERNAL_SERVER_ERROR', }, message: sanitizedErrorTitle, }; return result; }, });
await apolloServer.start();
app.use( '/graphql', express.json(), expressMiddleware(apolloServer, { context: async ({ req, res }) => ({ ...buildPassportContext(req, res), services: makeGqlServices(deps), dataSources: deps.DataSources, }), }), );
Object.entries(controllers).forEach(([_k, controller]) => { controller.routes.forEach((it) => { const handler = it.handler(deps); const handlers = Array.isArray(handler) ? handler : [handler]; // If the route declares a bodySchema, validate the request body against // it before any handler runs. Routes without a schema (e.g., GETs) skip // validation entirely. const middlewares = it.bodySchema ? [createBodySchemaValidator(it.bodySchema), ...handlers] : handlers; app[it.method](path.join(controller.pathPrefix, it.path), ...middlewares); }); });
// catch 404 and forward to error handler app.use(function (_req, _res, next) { next( makeNotFoundError('Requested route not found.', { shouldErrorSpan: true, }), ); });
// error handler app.use(async function (err, _req, res, _next) { await deps.Tracer.addActiveSpan( { resource: 'app', operation: 'handleError' }, async (span) => { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
// I don't know if these attributes are necessary, with recordException span.setAttribute(ATTR_EXCEPTION_MESSAGE, err.message); if (err.stack) { span.setAttribute(ATTR_EXCEPTION_STACKTRACE, err.stack); } span.setAttribute(ATTR_EXCEPTION_TYPE, err.name);
const errors = (() => { if (err instanceof AggregateError) { const extractedErrors = getErrorsFromAggregateError(err); return isNonEmptyArray(extractedErrors) ? extractedErrors : [err]; } else { return [err]; } })() satisfies NonEmptyArray<unknown>;
// If we had any nested errors (from an AggregateError), // attach those to the span too. if (errors.length > 1 || errors[0] !== err) { span.setAttribute( 'errors', jsonStringify( errors.map((it) => safePick(it, ['name', 'message', 'stack'])), ), ); }
// If we've already sent response headers or the response status code, // we can't actually send a different status code here: it's an error // in HTTP to send the headers portion of a response twice. So, we // need to skip this step. // // This can happen, e.g., if we have a request handler that // immediately responds with a 202/204 but then continues to do some // processing work in the background, and that work errors. if (!res.headersSent) { const safeErrors = errors.map((it) => sanitizeError(it), ) satisfies SerializableError[] as NonEmptyArray<SerializableError>;
res.status(pickStatus(safeErrors)).json({ errors: safeErrors }); } }, ); } as ErrorRequestHandler);
return { app, async shutdown() { await Promise.all([ apolloServer.stop(), deps.closeSharedResourcesForShutdown(), sessionStoreInstance.close(), ]); }, };}
function pickStatus(safeErrors: NonEmptyArray<SerializableError>) { return safeErrors[0].status;}
function makeGqlServices(deps: Dependencies) { return { ...safePick(deps, [ 'ApiKeyService', 'DataWarehouse', 'DerivedFieldsService', 'getItemTypeEventuallyConsistent', 'getEnabledRulesForItemTypeEventuallyConsistent', 'ItemInvestigationService', 'ModerationConfigService', 'ManualReviewToolService', 'HMAHashBankService', 'NcmecService', 'OrgSettingsService', 'PartialItemsService', 'ReportingService', 'RuleEvaluator', 'SignalsService', 'SigningKeyPairService', 'Tracer', 'UserManagementService', 'UserStatisticsService', 'UserHistoryQueries', 'UserStrikeService', 'SSOService', ]), // Calling sendEmail straight from a resolver is hella sketch, as the // resolvers shouldn’t have real business logic in them. Future sendEmail // calls should be encapsulated inside some business-logic-containing // service, and it’s that service that should be called from the resolvers. legacy_DO_NOT_USE_DIRECTLY_sendEmail: deps.sendEmail, };}
export type GQLServices = ReturnType<typeof makeGqlServices>;