import express, {NextFunction} from 'express'; import type {z} from 'zod'; import { ADMIN_PATH, ADMIN_LICENSES_ACTIVE_PATH, ADMIN_DOWNLOAD_PATH, ADMIN_POPULATE_PATH, ADMIN_LICENSE_PATH, CUSTOMER_ROUTE, LICENSE_STATUS_ROUTE, LICENSE_ACTIVE_ROUTE, apiVersionMinor, ADMIN_SIGNUPS_PATH, ADMIN_METRICS_PATH, ADMIN_METRICS_API_PATH, } from './api-types.js'; import { createCustomerRequestSchema, getLicenseStatusRequestSchema, licenseActiveRequestSchema, } from './api-types-schema.js'; import {createCustomer} from './customer.js'; import { newVersionError, ValidationError, VersionError, } from './handler-errors.js'; import {getLicenseStatus, licenseActive} from './license.js'; import cors from 'cors'; import { index as adminIndex, downloadActives, licenseView, activesView, populateForFun, newSignupsView, } from './admin.js'; import {populateMetrics} from './metrics.js'; import basicAuth from 'express-basic-auth'; import type {LogContext} from '@rocicorp/logger'; import path from 'path'; import {fileURLToPath} from 'url'; import {initializeApp, cert} from 'firebase-admin/app'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // TODO should probably figure out how to pass the parsed object // along so we aren't parsing it twice. // TODO we should probably log failures and not return verbose // info in the 500? const validateRequest = (schema: z.AnyZodObject) => async ( req: express.Request, res: express.Response, next: express.NextFunction, ) => { try { const {apiMajor: reqApiMajor, apiMinor: reqApiMinor} = req.params; if (reqApiMajor !== '1') { throw newVersionError(); } const reqApiMinorNumber = parseInt(reqApiMinor, 10); if ( reqApiMinorNumber === undefined || reqApiMinorNumber > apiVersionMinor ) { throw newVersionError(); } await schema.parseAsync(req.body); return next(); } catch (e) { res.status(400); if (e instanceof Error) { res.send(e.message); } } }; function intializeFirestoreApp() { const {GOOGLE_FIRESTORE_PROJECT_ID} = process.env; const {GOOGLE_FIRESTORE_SERVICE_ACCOUNT_EMAIL} = process.env; const GOOGLE_FIRESTORE_SERVICE_ACCOUNT_PRIVATE_KEY = ( process.env.GOOGLE_FIRESTORE_SERVICE_ACCOUNT_PRIVATE_KEY || '' ) .split(String.raw`\n`) .join('\n'); if ( GOOGLE_FIRESTORE_PROJECT_ID && GOOGLE_FIRESTORE_SERVICE_ACCOUNT_EMAIL && GOOGLE_FIRESTORE_SERVICE_ACCOUNT_PRIVATE_KEY ) { initializeApp({ credential: cert({ projectId: GOOGLE_FIRESTORE_PROJECT_ID, clientEmail: GOOGLE_FIRESTORE_SERVICE_ACCOUNT_EMAIL, privateKey: GOOGLE_FIRESTORE_SERVICE_ACCOUNT_PRIVATE_KEY, }), }); } } export function newApp(adminPw: string, lc: LogContext) { intializeFirestoreApp(); const app = express(); app.use(express.json()); app.get('/', (_: express.Request, res: express.Response) => { res.send('Rocicorp License Server'); }); // The license calls come from Replicache running on arbitrary origins. const corsOptions: cors.CorsOptions = { origin: '*', methods: 'POST, OPTIONS', allowedHeaders: ['Content-Type', 'Accept'], }; app.options( CUSTOMER_ROUTE, cors(corsOptions), (_: express.Request, __: express.Response, next: NextFunction) => { next(); }, ); app.post( CUSTOMER_ROUTE, cors(corsOptions), validateRequest(createCustomerRequestSchema), async (req: express.Request, res: express.Response) => { try { res.json(await createCustomer(req.body, lc)); } catch (e) { res.status( e instanceof ValidationError || e instanceof VersionError ? 400 : 500, ); res.send((e as Error).message); } }, ); app.options( LICENSE_STATUS_ROUTE, cors(corsOptions), (_: express.Request, __: express.Response, next: NextFunction) => { next(); }, ); app.post( LICENSE_STATUS_ROUTE, cors(corsOptions), validateRequest(getLicenseStatusRequestSchema), async (req: express.Request, res: express.Response) => { try { res.json(await getLicenseStatus(req.body)); } catch (e) { res.status( e instanceof ValidationError || e instanceof VersionError ? 400 : 500, ); res.send((e as Error).message); } }, ); app.options( LICENSE_ACTIVE_ROUTE, cors(corsOptions), (_: express.Request, __: express.Response, next: NextFunction) => { next(); }, ); app.post( LICENSE_ACTIVE_ROUTE, cors(corsOptions), validateRequest(licenseActiveRequestSchema), async (req: express.Request, res: express.Response) => { try { res.json(await licenseActive(req.body, lc)); } catch (e) { res.status( e instanceof ValidationError || e instanceof VersionError ? 400 : 500, ); res.send((e as Error).message); } }, ); // Admin functionality. // TODO for safety, probably move these endpoints to their own separate server const auth = basicAuth({ users: { admin: adminPw, }, challenge: true, }); app.get(ADMIN_PATH, auth, (req, res) => { adminIndex(req, res, lc); }); app.get(ADMIN_DOWNLOAD_PATH, auth, async (req, res) => { await downloadActives(req, res, lc); }); app.get(ADMIN_LICENSE_PATH, auth, async (req, res) => { await licenseView(req, res, lc); }); app.get(ADMIN_LICENSES_ACTIVE_PATH, auth, async (req, res) => { await activesView(req, res, lc); }); app.get(ADMIN_SIGNUPS_PATH, auth, async (req, res) => { await newSignupsView(req, res, lc); }); app.get(ADMIN_POPULATE_PATH, auth, async (_, res) => { await populateForFun(_, res, lc); }); app.use( ADMIN_METRICS_PATH, auth, express.static(path.join(__dirname, 'html')), ); app.get(ADMIN_METRICS_API_PATH, auth, async (req, res) => { await populateMetrics(req, res, lc); }); return app; }