diff --git a/.changeset/pink-hounds-bake.md b/.changeset/pink-hounds-bake.md new file mode 100644 index 000000000..0ca0a348a --- /dev/null +++ b/.changeset/pink-hounds-bake.md @@ -0,0 +1,5 @@ +--- +'@churros/api': minor +--- + +validate env vars before launching server diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ea3604fa4..9c0ea4952 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,6 +32,10 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-throw-literal': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/no-empty-object-type': [ + 'error', + { allowInterfaces: 'with-single-extends' }, + ], '@typescript-eslint/no-unused-vars': [ 'error', { diff --git a/packages/api/package.json b/packages/api/package.json index 52c1ef7ec..2a119433d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -33,6 +33,7 @@ "arborist": "workspace:^0.1.1", "argon2": "^0.41.1", "bunyan": "^1.8.15", + "chalk": "^5.3.0", "cli": "^1.0.1", "cli-progress": "^3.12.0", "colord": "^2.9.3", @@ -74,6 +75,7 @@ "linkify-plugin-mention": "^4.1.3", "linkifyjs": "^4.1.3", "lodash.countby": "^4.6.0", + "lodash.get": "^4.4.2", "lodash.groupby": "^4.6.0", "lodash.omit": "^4.5.0", "lodash.range": "^3.2.0", @@ -143,6 +145,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/ldapjs": "^3.0.6", "@types/lodash.countby": "^4.6.9", + "@types/lodash.get": "^4", "@types/lodash.omit": "^4.5.9", "@types/lodash.range": "^3.2.9", "@types/mjml": "^4.7.4", diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts new file mode 100644 index 000000000..a58b9ca26 --- /dev/null +++ b/packages/api/src/env.ts @@ -0,0 +1,249 @@ +import { z } from 'zod'; + +export const environmentSchema = z.object({ + DATABASE_URL: uri('postgres').describe( + 'Database connection string. See https://www.prisma.io/docs/reference/database-reference/connection-urls', + ), + REDIS_URL: uri('redis').describe('Redis connection string.'), + PROMETHEUS_URL: optionaluri().describe('Prometheus pushgateway URL.'), + PUBLIC_FRONTEND_ORIGIN: uri() + .refine((url) => !url.endsWith('/')) + .describe("Public frontend origin. Without trailing slash, it's an origin."), + INIT_CWD: z + .string() + .describe('See https://yarnpkg.com/advanced/lifecycle-scripts/#environment-variables'), + LDAP_SCHOOL: jsonobject({ + servers: z.record( + z.object({ + url: uri('ldap', 'ldaps').describe('URL of the LDAP server.'), + filterAttribute: z.string().describe('Attribute to use for filtering.'), + wholeEmail: z.boolean().describe('Whether the email is the whole login.'), + attributesMap: z + .object({ + schoolUid: z.string().describe('Attribute for the school UID.'), + schoolEmail: z.string().describe('Attribute for the school email.'), + firstName: z.string().describe('Attribute for the first name.'), + lastName: z.string().describe('Attribute for the last name.'), + }) + .describe('Mapping of attributes.'), + }), + ), + emailDomains: z + .record(z.string()) + .describe('Map student email domains to their corresponding LDAP servers.'), + }) + .optional() + .describe('Settings object for school LDAP servers.'), + PROJECT_CWD: z + .string() + .describe('See https://yarnpkg.com/advanced/lifecycle-scripts/#environment-variables'), + PUBLIC_API_URL: uri().describe('Publicly accessible URL to the (GraphQL) API.'), + PUBLIC_API_AUTH_URL: uri().describe('Publicly accessible URL to the auth API.'), + PUBLIC_API_WEBSOCKET_URL: uri('ws', 'wss').describe( + 'Publicly accessible URL to the websocket API.', + ), + PUBLIC_STORAGE_URL: uri().describe('Publicly accessible URL to the storage API.'), + SMTP_URL: optionaluri('smtp').describe( + 'SMTP string, to send emails. See https://nodemailer.com/smtp/', + ), + STORAGE: z.string().describe('Storage directory, relative to to working directory of the API.'), + PUBLIC_SUPPORT_EMAIL: z + .string() + .email() + .describe('The email from which will be sent all emails.'), + PUBLIC_LYDIA_API_URL: uri().describe('Lydia API URL.'), + LYDIA_WEBHOOK_URL: uri().describe( + 'Lydia webhook URL: Where Lydia should notify us of payment acknowledgements.', + ), + PUBLIC_FOY_GROUPS: z.string().describe('DEPRECATED.').optional(), + PUBLIC_VAPID_KEY: z.string().describe('Public VAPID key. Used to send push notifications.'), + VAPID_PRIVATE_KEY: z.string().describe('Private VAPID key. Used to send push notifications.'), + PUBLIC_CONTACT_EMAIL: z.string().email().describe('Contact email.'), + GITLAB_PROJECT_ID: z + .string() + .regex(/^\d+$/) + .describe("Internal ID of the Churros project's repo, at https://git.inpt.fr/inp-net/churros.") + .default('1013'), + GITLAB_SUDO_TOKEN: z + .string() + .optional() + .describe( + 'Personal access token with sudo, read_api and api scopes. Sudo is required to impersonate a user.', + ), + LDAP_URL: uri('ldap', 'ldaps').describe('LDAP URL.'), + LDAP_BASE_DN: z.string().describe('LDAP base DN.'), + LDAP_BIND_DN: z.string().describe('LDAP bind DN.'), + LDAP_BIND_PASSWORD: z.string().describe('LDAP bind password.'), + MASTER_PASSWORD_HASH: z + .string() + .regex(/^\$argon2id\$.*$/) + .optional() + .or(z.literal('')) // See https://github.com/colinhacks/zod/issues/310 + .describe('argon2 hash of the master password.'), + PUBLIC_PAYPAL_CLIENT_ID: z.string().describe('Paypal client ID.'), + PAYPAL_CLIENT_SECRET: z.string().describe('Paypal client secret.'), + PUBLIC_PAYPAL_API_BASE_URL: uri().describe('Paypal API base URL.'), + PUBLIC_SCHOOL_UID: z + .string() + .describe( + "UID of the school to use for the logged-out view of the homepage. The 'main' school of the deployment.", + ), + GOOGLE_CLIENT_SECRET: z.string().optional().describe('Google APIs client secret.'), + PUBLIC_GOOGLE_CLIENT_ID: z.string().optional().describe('Google APIs client ID.'), + PUBLIC_GOOGLE_WALLET_ISSUER_ID: z.string().optional().describe('Google Wallet issuer ID.'), + GOOGLE_WALLET_ISSUER_KEY: googleServiceAccountKey().describe('Google Wallet issuer key.'), + PUBLIC_DEACTIVATE_SIGNUPS: z + .enum(['true', 'false']) + .describe('Set to "true" to deactivate signups.'), + PUBLIC_DEACTIVATE_SIGNUPS_MESSAGE: z + .string() + .optional() + .describe('Custom message to show when users try to hit the /register page.'), + PUBLIC_OAUTH_ENABLED: z.enum(['1', '0']).describe('Set to "1" to activate oauth2 login.'), + PUBLIC_OAUTH_CLIENT_ID: z.string().optional().describe('Oauth2 client ID.'), + PUBLIC_OAUTH_AUTHORIZE_URL: optionaluri().describe('Oauth2 authorize URL.'), + PUBLIC_OAUTH_TOKEN_URL: optionaluri().describe('Oauth2 token URL.'), + PUBLIC_OAUTH_USER_INFO_URL: optionaluri().describe('Oauth2 user info URL.'), + PUBLIC_OAUTH_LOGOUT_URL: optionaluri().describe('Oauth2 logout URL.'), + PUBLIC_OAUTH_SCOPES: z + .string() + .optional() + .describe('Oauth2 scopes, comma separated.') + .transform((scopes) => (scopes ? scopes.split(',').map((scope) => scope.trim()) : undefined)), + OAUTH_UID_KEY: z + .string() + .optional() + .describe( + "Property to use for the user's uid. Has to be accessible in the user info response.", + ), + OAUTH_CLIENT_SECRET: z.string().optional().describe('Oauth2 client secret.'), + SESSION_SECRET: z.string().describe('express-session secret.'), + APPLE_WALLET_PEM_CERTIFICATE: z.string().optional().describe('Contents of the .pem certificate.'), + APPLE_WALLET_PEM_KEY_PASSWORD: z + .string() + .optional() + .describe("The private key's optional password."), + APPLE_WALLET_PASS_TYPE_ID: z.string().optional().describe('Apple Wallet pass type ID.'), + APPLE_WALLET_TEAM_ID: z.string().optional().describe('Apple Wallet team ID.'), + MAILMAN_API_URL: optionaluri().describe('Mailman API URL to handle mailing lists automation.'), + MAILMAN_API_TOKEN: z.string().optional().describe('Mailman API token.'), + PUBLIC_GLOBAL_SEARCH_BUMPS: optionaljsonobject({ + Users: z.number().optional(), + Groups: z.number().optional(), + Events: z.number().optional(), + Articles: z.number().optional(), + Documents: z.number().optional(), + }).describe( + "Additive modifier for favoring some types in global search results. A search result's rank is between 0 and 1. JSON object mapping types to rank bumps. Types are values of the `SearchResultType` GraphQL enum. Omitting a value means no bump.", + ), + PUBLIC_API_ORIGIN_WEB: optionaluri().describe( + "Origin of the public API for the web client. Defaults to PUBLIC_API_URL's origin.", + ), + PUBLIC_API_ORIGIN_ANDROID: optionaluri().describe( + "Origin of the public API for the Android client. Defaults to PUBLIC_API_URL's origin.", + ), + PUBLIC_API_ORIGIN_IOS: optionaluri().describe( + "Origin of the public API for the iOS client. Defaults to PUBLIC_API_URL's origin.", + ), + PUBLIC_FRONTEND_ORIGIN_ANDROID: optionaluri().describe( + 'Origin of the public frontend for the Android client. Defaults to PUBLIC_FRONTEND_ORIGIN.', + ), + PUBLIC_FRONTEND_ORIGIN_IOS: optionaluri().describe( + 'Origin of the public frontend for the iOS client. Defaults to PUBLIC_FRONTEND_ORIGIN.', + ), + FIREBASE_ADMIN_SERVICE_ACCOUNT_KEY: googleServiceAccountKey().describe( + 'Firebase Admin SDK service account key. JSON contents of the key file, can be downloaded from the Firebase Admin console. Used to send push notifications to Android & iOS native clients.', + ), + PUBLIC_APP_PACKAGE_ID: z + .string() + .regex(/^[\d.a-z]+$/) + .describe('App package ID (Android) and Bundle ID (iOS).'), +}); + +function googleServiceAccountKey() { + return optionaljsonobject({ + type: z.literal('service_account'), + project_id: z.string(), + private_key_id: z.string(), + private_key: privatekey(), + client_email: z.string().email(), + client_id: z.string(), + auth_uri: uri(), + token_uri: uri(), + auth_provider_x509_cert_url: uri(), + client_x509_cert_url: uri(), + universe_domain: z.string(), + }); +} + +function uri(...protocols: string[]) { + return z + .string() + .trim() + .url() + .refine((url) => + (protocols.length > 0 ? protocols : ['http', 'https']).some((protocol) => + url.startsWith(`${protocol}://`), + ), + ); +} + +function optionaluri(...protocols: string[]) { + return z + .string() + .trim() + .url() + .refine((url) => + (protocols.length > 0 ? protocols : ['http', 'https']).some((protocol) => + url.startsWith(`${protocol}://`), + ), + ) + .or(z.literal('').transform(() => {})) + .optional(); +} + +function jsonobject( + shape: Shape, + params?: Params, +) { + return z + .string() + .transform((x, ctx) => { + // See https://github.com/colinhacks/zod/issues/2918#issuecomment-1800824755 + try { + return JSON.parse(x); + } catch (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid JSON: ${error}` }); + return z.NEVER; + } + }) + .pipe(z.object(shape, params)); +} + +function optionaljsonobject( + shape: Shape, + params?: Params, +) { + return z + .string() + .optional() + .transform((x, ctx) => { + if (!x) return; + // See https://github.com/colinhacks/zod/issues/2918#issuecomment-1800824755 + try { + return JSON.parse(x); + } catch (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid JSON: ${error}` }); + return z.NEVER; + } + }) + .pipe(z.object(shape, params).or(z.undefined())); +} + +function privatekey() { + return z + .string() + .trim() + .startsWith('-----BEGIN PRIVATE KEY-----') + .endsWith('-----END PRIVATE KEY-----'); +} diff --git a/packages/api/src/global.d.ts b/packages/api/src/global.d.ts index 2c2e8bdc5..86d6daec6 100644 --- a/packages/api/src/global.d.ts +++ b/packages/api/src/global.d.ts @@ -1,123 +1,9 @@ -import { SessionUser } from '#lib'; +import { z } from 'zod'; +import { environmentSchema } from './env.js'; declare global { namespace NodeJS { - export interface ProcessEnv { - /** - * Database connection string. - * - * @see https://www.prisma.io/docs/reference/database-reference/connection-urls - */ - DATABASE_URL: string; - /** - * Redis connection string. - */ - REDIS_URL: string; - /** - * Prometheus pushgateway URL. - */ - PROMETHEUS_URL: string; - /** - * Public frontend origin. - * - * @example - * 'https://example.com'; - * - * @remark /!\ Without trailing slash, it's an origin - */ - PUBLIC_FRONTEND_ORIGIN: string; - /** @see https://yarnpkg.com/advanced/lifecycle-scripts/#environment-variables */ - INIT_CWD: string; - /** Settings object for reference LDAP servers. */ - LDAP_SCHOOL: string; - /** @see https://yarnpkg.com/advanced/lifecycle-scripts/#environment-variables */ - PROJECT_CWD: string; - PUBLIC_API_URL: string; - PUBLIC_API_AUTH_URL: string; - PUBLIC_API_WEBSOCKET_URL: string; - PUBLIC_STORAGE_URL: string; - /** - * SMTP options string. - * - * @see https://nodemailer.com/smtp/ - */ - SMTP_URL: string; - /** Storage directory, relative to to working directory of the API. */ - STORAGE: string; - /** The email from which will be sent all emails. */ - PUBLIC_SUPPORT_EMAIL: string; - /** Lydia API URL. */ - PUBLIC_LYDIA_API_URL: string; - /** - * Foy groups. Only users that are part of the bureau of one of these groups can create/modify - * bar weeks. Must be a comma-separated list of group UIDs. - */ - PUBLIC_FOY_GROUPS: string; - /** VAPID keys */ - PUBLIC_VAPID_KEY: string; - VAPID_PRIVATE_KEY: string; - /** Contact email (used for push notifications and maybe other things) */ - PUBLIC_CONTACT_EMAIL: string; - /** Gitlab API info, for the issue creation form */ - GITLAB_PROJECT_ID: string; // Id of the repo - GITLAB_SUDO_TOKEN: string; // personnal access token with sudo, read_api and api scopes. Sudo is required to impersonate a user so that we can say that he created it - /** LDAP URL */ - LDAP_URL: string; - LDAP_BASE_DN: string; - LDAP_BIND_DN: string; - LDAP_BIND_PASSWORD: string; - /** Master password's hash (allows impersonation) */ - MASTER_PASSWORD_HASH: string; - /** Paypal secrets */ - PUBLIC_PAYPAL_CLIENT_ID: string; - PAYPAL_CLIENT_SECRET: string; - PUBLIC_PAYPAL_API_BASE_URL: string; - PUBLIC_SCHOOL_UID: string; - /** Google secrets */ - GOOGLE_CLIENT_SECRET: string; - PUBLIC_GOOGLE_CLIENT_ID: string; - PUBLIC_GOOGLE_WALLET_ISSUER_ID: string; - - GOOGLE_WALLET_ISSUER_KEY: string; - /** Mailman secrets */ - MAILMAN_API_URL: string; - MAILMAN_API_TOKEN: string; - /** Set to "true" to deactivate signups */ - PUBLIC_DEACTIVATE_SIGNUPS: string; - /** Custom message to show when users try to hit the /register page */ - PUBLIC_DEACTIVATE_SIGNUPS_MESSAGE: string; - - /** Set to "true" to activate oauth2 login */ - PUBLIC_OAUTH_ENABLED: string; - /** Oauth2 client secrets */ - PUBLIC_OAUTH_CLIENT_ID: string; - PUBLIC_OAUTH_AUTHORIZE_URL: string; - PUBLIC_OAUTH_TOKEN_URL: string; - PUBLIC_OAUTH_USER_INFO_URL: string; - PUBLIC_OAUTH_LOGOUT_URL: string; - /** Oauth2 scopes, comma separated */ - PUBLIC_OAUTH_SCOPES: string; - /** Oauth2 UID key */ - OAUTH_UID_KEY: string; - OAUTH_CLIENT_SECRET: string; - - /** express-session secret */ - SESSION_SECRET: string; - /** Contents of the .pem certificate. See .env.example for instructions to get this file */ - APPLE_WALLET_PEM_CERTIFICATE: string; - /** The private key's optional password. For net7 devs, this is in the vault. */ - APPLE_WALLET_PEM_KEY_PASSWORD: string; - APPLE_WALLET_PASS_TYPE_ID: string; - /** Get it from https://developer.apple.com/account */ - APPLE_WALLET_TEAM_ID: string; - - /** Additive modifiers for favoring some types in global search results. */ - PUBLIC_GLOBAL_SEARCH_BUMPS: string; - PUBLIC_API_ORIGIN_ANDROID: string; - PUBLIC_API_ORIGIN_WEB: string; - FIREBASE_ADMIN_SERVICE_ACCOUNT_KEY: string; - PUBLIC_APP_PACKAGE_ID: string; - } + export interface ProcessEnv extends Record, string> {} } namespace Express { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index cb169bb13..a3e5bba32 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -6,6 +6,8 @@ import { lydiaWebhook } from './server/lydia.js'; import { maintenance } from './server/maintenance.js'; import { prometheusServer } from './server/prometheus.js'; +// Validates env variables before doing anything else +import './lib/env.js'; startApiServer(); lydiaWebhook.listen(4001, () => { diff --git a/packages/api/src/lib/apple-wallet.ts b/packages/api/src/lib/apple-wallet.ts index d4da9056e..ec0516fd8 100644 --- a/packages/api/src/lib/apple-wallet.ts +++ b/packages/api/src/lib/apple-wallet.ts @@ -6,27 +6,35 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path/posix'; import sharp from 'sharp'; +import { ENV } from './env.js'; import { localID } from './global-id.js'; import { storageRoot } from './storage.js'; let appleWalletTemplate: Template | null = null; export async function writePemCertificate(to: string) { - if (!process.env.APPLE_WALLET_PEM_CERTIFICATE) { + const certificate = ENV.APPLE_WALLET_PEM_CERTIFICATE; + if (!certificate) { console.warn( `No APPLE_WALLET_PEM_CERTIFICATE set in environment variables, not writing certificate to ${to}.`, ); return; } - await writeFile(to, process.env.APPLE_WALLET_PEM_CERTIFICATE); + await writeFile(to, certificate); } export async function registerAppleWalletPassTemplate() { if (appleWalletTemplate) return; + if (!ENV.APPLE_WALLET_PASS_TYPE_ID || !ENV.APPLE_WALLET_TEAM_ID) { + console.warn( + 'No APPLE_WALLET_PASS_TYPE_ID or APPLE_WALLET_TEAM_ID set in environment variables, not registering Apple Wallet pass template.', + ); + return; + } console.info('Registering Apple Wallet pass template...'); const template = new Template('eventTicket', { - passTypeIdentifier: process.env.APPLE_WALLET_PASS_TYPE_ID, - teamIdentifier: process.env.APPLE_WALLET_TEAM_ID, + passTypeIdentifier: ENV.APPLE_WALLET_PASS_TYPE_ID, + teamIdentifier: ENV.APPLE_WALLET_TEAM_ID, backgroundColor: 'white', sharingProhibited: true, organizationName: 'net7', // TODO: dehardcode? @@ -44,10 +52,7 @@ export async function registerAppleWalletPassTemplate() { appleWalletTemplate = null; return; } - await template.loadCertificate( - 'apple-wallet-cert.pem', - process.env.APPLE_WALLET_PEM_KEY_PASSWORD, - ); + await template.loadCertificate('apple-wallet-cert.pem', ENV.APPLE_WALLET_PEM_KEY_PASSWORD); appleWalletTemplate = template; return appleWalletTemplate; } @@ -70,7 +75,7 @@ export async function createAppleWalletPass( relevantDate: subMinutes(booking.ticket.event.startsAt, 5).toISOString(), barcodes: [ { - message: new URL(`/bookings/${booking.id}`, process.env.PUBLIC_FRONTEND_ORIGIN).toString(), + message: new URL(`/bookings/${booking.id}`, ENV.PUBLIC_FRONTEND_ORIGIN).toString(), format: 'PKBarcodeFormatQR', messageEncoding: 'iso-8859-1', altText: localID(booking.id).toUpperCase(), @@ -104,7 +109,7 @@ export async function createAppleWalletPass( key: 'code', label: 'Code de réservation', value: localID(booking.id).toUpperCase(), - attributedValue: `${localID(booking.id).toUpperCase()}`, + attributedValue: `${localID(booking.id).toUpperCase()}`, }); pass.primaryFields.add({ key: 'title', @@ -126,7 +131,7 @@ export async function createAppleWalletPass( booking.internalBeneficiary || booking.author ? new URL( `/${booking.internalBeneficiary?.uid ?? booking.author?.uid}`, - process.env.PUBLIC_FRONTEND_ORIGIN, + ENV.PUBLIC_FRONTEND_ORIGIN, ) : null; @@ -180,7 +185,7 @@ export async function createAppleWalletPass( pass.backFields.add({ key: 'platform', label: 'Acheté sur', - value: new URL(process.env.PUBLIC_FRONTEND_ORIGIN).hostname, + value: new URL(ENV.PUBLIC_FRONTEND_ORIGIN).hostname, }); if (booking.ticket.name || booking.ticket.event._count.tickets > 1) { pass.headerFields.add({ diff --git a/packages/api/src/lib/builder.ts b/packages/api/src/lib/builder.ts index cab53c1c1..1d1009ee7 100644 --- a/packages/api/src/lib/builder.ts +++ b/packages/api/src/lib/builder.ts @@ -1,5 +1,6 @@ import type { Context, GraphinxDirective, RateLimitDirective } from '#lib'; import { + ENV, authScopes, context, decodeGlobalID, @@ -215,7 +216,7 @@ builder.mutationType({ builder.subscriptionType({ description: `Permet de faire des requêtes de données temps-réel, via des _websockets_. -L'endpoint pour le temps réel est \`${process.env.PUBLIC_API_WEBSOCKET_URL}\`. +L'endpoint pour le temps réel est \`${ENV.PUBLIC_API_WEBSOCKET_URL}\`. Pour un client JavaScript, il y a par exemple [GraphQL-WebSocket](https://the-guild.dev/graphql/ws/get-started#use-the-client)`, directives: { diff --git a/packages/api/src/lib/env.ts b/packages/api/src/lib/env.ts index b6d7d4495..ca7948e9d 100644 --- a/packages/api/src/lib/env.ts +++ b/packages/api/src/lib/env.ts @@ -1,6 +1,70 @@ +import chalk from 'chalk'; +import getByPath from 'lodash.get'; +import { z } from 'zod'; +import { environmentSchema } from '../env.js'; +import { isValidJSON } from './utils.js'; +export { environmentSchema } from '../env.js'; + /** * Returns true if the server is running in development mode. */ export function inDevelopment() { return process.env['NODE_ENV'] === 'development'; } + +let _parsedEnv: z.infer | undefined; + +export const ENV = new Proxy>({} as NonNullable, { + get(_target, property: keyof typeof environmentSchema.shape) { + if (_parsedEnv) return _parsedEnv[property]; + + try { + console.info('Validating environment variables...'); + _parsedEnv = environmentSchema.parse(process.env); + return _parsedEnv[property]; + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + + console.error(chalk.red('Some environment variables are invalid:')); + formatZodErrors(error); + throw new Error('Invalid environment variables.'); + } + }, +}); + +function formatZodErrors(error: z.ZodError) { + for (const { path, message } of error.issues) { + const variable = path[0]?.toString() as keyof typeof environmentSchema.shape; + const value = process.env[variable]; + const description = environmentSchema.shape[variable]?.description; + + if (path.length !== 1) { + console.error( + formatZodIssue( + description, + path.join('.'), + message, + path.length === 1 || !isValidJSON(value) + ? value + : getByPath(JSON.parse(value), path.slice(1)), + ), + ); + continue; + } + + console.error(formatZodIssue(description, variable, message, value)); + } +} + +function formatZodIssue( + description: string | undefined, + path: string, + message: string, + value: string | undefined, +) { + return [ + description ? chalk.dim(`\n# ${description}`) : '', + `${chalk.bold.cyan(path)}: ${message}`, + `Current value: ${chalk.green(JSON.stringify(value))}`, + ].join('\n'); +} diff --git a/packages/api/src/lib/google-wallet.ts b/packages/api/src/lib/google-wallet.ts index 79faa1d8f..31328d9a4 100644 --- a/packages/api/src/lib/google-wallet.ts +++ b/packages/api/src/lib/google-wallet.ts @@ -1,10 +1,9 @@ import { fullName } from '#modules/users'; import type { Prisma } from '@churros/db/prisma'; -import { GoogleAuth, type JWTInput } from 'google-auth-library'; +import { GoogleAuth } from 'google-auth-library'; +import { ENV } from './env.js'; import { localID } from './global-id.js'; -const GOOGLE_WALLET_ISSUER_ID = process.env.PUBLIC_GOOGLE_WALLET_ISSUER_ID; - const baseUrl = 'https://walletobjects.googleapis.com/walletobjects/v1'; // Google Wallet does not like localhost URLs, so we replace localhost:* with churros.inpt.fr, even in dev. @@ -31,10 +30,7 @@ export function makeGoogleWalletObject( classId: GOOGLE_WALLET_CLASS.id, logo: { sourceUri: { - uri: noLocalhostURL( - 'android-chrome-512x512.png', - process.env.PUBLIC_FRONTEND_ORIGIN, - ).toString(), + uri: noLocalhostURL('android-chrome-512x512.png', ENV.PUBLIC_FRONTEND_ORIGIN).toString(), }, contentDescription: { defaultValue: { @@ -97,11 +93,8 @@ export function makeGoogleWalletObject( heroImage: { sourceUri: { uri: event.pictureFile - ? noLocalhostURL(event.pictureFile, process.env.PUBLIC_STORAGE_URL).toString() - : noLocalhostURL( - 'android-chrome-512x512.png', - process.env.PUBLIC_FRONTEND_ORIGIN, - ).toString(), + ? noLocalhostURL(event.pictureFile, ENV.PUBLIC_STORAGE_URL).toString() + : noLocalhostURL('android-chrome-512x512.png', ENV.PUBLIC_FRONTEND_ORIGIN).toString(), }, contentDescription: { defaultValue: { @@ -128,7 +121,7 @@ makeGoogleWalletObject.prismaIncludes = { } as const satisfies Prisma.RegistrationInclude; export const GOOGLE_WALLET_CLASS = { - id: `${GOOGLE_WALLET_ISSUER_ID}.churros_event`, + id: `${ENV.PUBLIC_GOOGLE_WALLET_ISSUER_ID}.churros_event`, classTemplateInfo: { cardTemplateOverride: { cardRowTemplateInfos: [ @@ -182,15 +175,14 @@ export const GOOGLE_WALLET_CLASS = { }; export async function registerGoogleWalletClass(data: typeof GOOGLE_WALLET_CLASS): Promise { - let credentials: JWTInput; - try { - credentials = JSON.parse(process.env.GOOGLE_WALLET_ISSUER_KEY); - } catch (error) { - console.error(`Could not parse credentials for Google Wallet issuer service account: ${error}`); + if (!ENV.GOOGLE_WALLET_ISSUER_KEY) { + console.warn( + 'No GOOGLE_WALLET_ISSUER_KEY set in environment variables, not registering Google Wallet class.', + ); return ''; } const httpClient = new GoogleAuth({ - credentials, + credentials: ENV.GOOGLE_WALLET_ISSUER_KEY, scopes: 'https://www.googleapis.com/auth/wallet_object.issuer', }); try { diff --git a/packages/api/src/lib/ldap-school.ts b/packages/api/src/lib/ldap-school.ts index 8f92168fc..ddf9fc0fd 100644 --- a/packages/api/src/lib/ldap-school.ts +++ b/packages/api/src/lib/ldap-school.ts @@ -2,6 +2,7 @@ import { fromYearTier } from '#lib'; import bunyan from 'bunyan'; import ldap from 'ldapjs'; +import { ENV } from './env.js'; const logger = bunyan.createLogger({ name: 'CRI INP ldap', level: 'debug' }); @@ -12,13 +13,7 @@ export interface LdapUser { lastName?: string; } -export const schoolLdapSettings = JSON.parse(process.env.LDAP_SCHOOL || '{}') as { - servers: Record< - string, - { url: string; filterAttribute: string; wholeEmail: boolean; attributesMap: LdapUser } - >; - emailDomains: Record; -}; +export const schoolLdapSettings = ENV.LDAP_SCHOOL; function parseN7ApprenticeAndMajor(groups: string[] | undefined): | undefined @@ -32,6 +27,7 @@ function parseN7ApprenticeAndMajor(groups: string[] | undefined): if (group.startsWith('cn=n7etu_')) { const fragment = group.split(',')[0]; if (!fragment) return undefined; + // TODO use document obtained after DSI ticket const parts = /n7etu_(?SN|MF2E|3EA)_(?\d)A(_APP)?/.exec(fragment); if (!parts) return; return { @@ -69,6 +65,8 @@ export const findSchoolUser = async ( }) | undefined > => { + if (!schoolLdapSettings) return; + let ldapFilter = ''; let schoolServer: string | undefined; console.log({ findSchoolUser: searchBy }); diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts index 8f3146bbe..363a6f7f5 100644 --- a/packages/api/src/lib/logger.ts +++ b/packages/api/src/lib/logger.ts @@ -1,4 +1,5 @@ -import { inDevelopment, prisma, publish } from '#lib'; +import { prisma, publish } from '#lib'; +import { inDevelopment } from './env.js'; export async function log( area: string, diff --git a/packages/api/src/lib/mail.ts b/packages/api/src/lib/mail.ts index 0543d4f2a..6f02532a6 100644 --- a/packages/api/src/lib/mail.ts +++ b/packages/api/src/lib/mail.ts @@ -15,12 +15,13 @@ import { type MailRequiredContentIDs, type MailTemplate, } from '../mail-templates/props.js'; +import { ENV } from './env.js'; const compiledTemplates = await precompileTemplates(); let mailer: ReturnType; function initializeMailer() { - return createTransport(process.env.SMTP_URL || 'smtp://localhost'); + return createTransport(ENV.SMTP_URL || 'smtp://localhost'); } /** @@ -44,7 +45,7 @@ export async function sendMail