UNPKG

@bdelab/roar-firekit

Version:

A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps

474 lines (473 loc) 21.4 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.sanitizeInput = exports.validateFileExtension = exports.singularizeFirestoreCollection = exports.pluralizeFirestoreCollection = exports.chunkOrgLists = exports.getTreeTableOrgs = exports.crc32String = exports.mergeGameParams = exports.emptyOrgList = exports.emptyOrg = exports.initializeFirebaseProject = exports.AuthPersistence = exports.safeInitializeApp = exports.replaceValues = exports.removeUndefined = exports.removeNull = void 0; const app_1 = require("firebase/app"); const auth_1 = require("firebase/auth"); // import { initializeAppCheck, ReCaptchaEnterpriseProvider, getToken } from 'firebase/app-check'; const firestore_1 = require("firebase/firestore"); const functions_1 = require("firebase/functions"); const storage_1 = require("firebase/storage"); const performance_1 = require("firebase/performance"); const chunk_1 = __importDefault(require("lodash/chunk")); const difference_1 = __importDefault(require("lodash/difference")); const flatten_1 = __importDefault(require("lodash/flatten")); const get_1 = __importDefault(require("lodash/get")); const invert_1 = __importDefault(require("lodash/invert")); const isEmpty_1 = __importDefault(require("lodash/isEmpty")); const isEqual_1 = __importDefault(require("lodash/isEqual")); const isPlainObject_1 = __importDefault(require("lodash/isPlainObject")); const mergeWith_1 = __importDefault(require("lodash/mergeWith")); const remove_1 = __importDefault(require("lodash/remove")); const vue_1 = require("vue"); const crc_32_1 = require("crc-32"); /** Remove null attributes from an object * @function * @param {Object} obj - Object to remove null attributes from * @returns {Object} Object with null attributes removed */ const removeNull = (obj) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== null)); }; exports.removeNull = removeNull; /** Remove undefined attributes from an object * @function * @param {Object} obj - Object to remove undefined attributes from * @returns {Object} Object with undefined attributes removed */ const removeUndefined = (obj) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)); }; exports.removeUndefined = removeUndefined; /** Recursively replace values in an object * @function * @param {Object} obj - Object to recursively replace values in * @param {unknown} valueToReplace - Value to replace * @param {unknown} replacementValue - Replacement value * @returns {Object} Object with values recursively replaced */ const replaceValues = (obj, valueToReplace = undefined, replacementValue = null) => { return Object.fromEntries(Object.entries(obj).map(([key, value]) => { if ((0, isPlainObject_1.default)(value)) { return [key, (0, exports.replaceValues)(value, valueToReplace, replacementValue)]; } return [key, value === valueToReplace ? replacementValue : value]; })); }; exports.replaceValues = replaceValues; const safeInitializeApp = (config, name) => { try { const app = (0, app_1.getApp)(name); if (!(0, isEqual_1.default)(app.options, config)) { throw new Error(`There is an existing firebase app named ${name} with different configuration options.`); } return app; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error) { if (error.code === 'app/no-app') { return (0, app_1.initializeApp)(config, name); } else { throw error; } } }; exports.safeInitializeApp = safeInitializeApp; // export const initializeAppCheckWithRecaptcha = (app: FirebaseApp, siteKey: string, debugToken: string | undefined) => { // const hostname = window.location.hostname; // // // Use the DEBUG reCAPTCHA key for local development // // This allows us to bypass the reCAPTCHA domain verification // // Debug token is a private key passed in from a .env file and should not be exposed // if (hostname === 'localhost') { // try { // // eslint-disable-next-line @typescript-eslint/no-explicit-any // (self as any).FIREBASE_APPCHECK_DEBUG_TOKEN = debugToken; // } catch (error) { // throw new Error(`Error setting App Check debug token: ${error}`); // } // } // // try { // return initializeAppCheck(app, { // provider: new ReCaptchaEnterpriseProvider(siteKey as string), // isTokenAutoRefreshEnabled: true, // }); // } catch (error) { // throw new Error(`Error initializing App Check with reCAPTCHA provider: ${error}`); // } // }; var AuthPersistence; (function (AuthPersistence) { AuthPersistence["local"] = "local"; AuthPersistence["session"] = "session"; AuthPersistence["none"] = "none"; })(AuthPersistence = exports.AuthPersistence || (exports.AuthPersistence = {})); const initializeFirebaseProject = (config, name, authPersistence = AuthPersistence.session, markRawConfig = {}) => __awaiter(void 0, void 0, void 0, function* () { const optionallyMarkRaw = (productKey, productInstance) => { if ((0, get_1.default)(markRawConfig, productKey)) { return (0, vue_1.markRaw)(productInstance); } else { return productInstance; } }; if (config.emulatorPorts) { const app = (0, app_1.initializeApp)({ projectId: config.projectId, apiKey: config.apiKey }, name); const ports = config.emulatorPorts; const auth = optionallyMarkRaw('auth', (0, auth_1.getAuth)(app)); const db = optionallyMarkRaw('db', (0, firestore_1.getFirestore)(app)); const functions = optionallyMarkRaw('functions', (0, functions_1.getFunctions)(app)); const storage = optionallyMarkRaw('storage', (0, storage_1.getStorage)(app)); (0, firestore_1.connectFirestoreEmulator)(db, '127.0.0.1', ports.db); (0, functions_1.connectFunctionsEmulator)(functions, '127.0.0.1', ports.functions); const originalInfo = console.info; // eslint-disable-next-line @typescript-eslint/no-empty-function console.info = () => { }; (0, auth_1.connectAuthEmulator)(auth, `http://127.0.0.1:${ports.auth}`); console.info = originalInfo; return { firebaseApp: app, auth, db, functions, storage, }; } else { // const { siteKey, debugToken, ...appConfig } = config as LiveFirebaseConfig; const appConfig = __rest(config, []); const app = (0, exports.safeInitializeApp)(appConfig, name); // Initialize App Check with reCAPTCHA provider before calling any other Firebase services // Get the App Check token for use in Axios calls to Firebase from the client // const appCheck = initializeAppCheckWithRecaptcha(app, siteKey, debugToken); // const { token: appCheckToken } = await getToken(appCheck); let performance = undefined; try { performance = (0, performance_1.getPerformance)(app); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error) { if (error.code !== 'performance/FB not default') { throw error; } } const kit = { firebaseApp: app, // appCheckToken: appCheckToken, auth: optionallyMarkRaw('auth', (0, auth_1.getAuth)(app)), db: optionallyMarkRaw('db', (0, firestore_1.getFirestore)(app)), functions: optionallyMarkRaw('functions', (0, functions_1.getFunctions)(app)), storage: optionallyMarkRaw('storage', (0, storage_1.getStorage)(app)), perf: performance, }; // Auth state persistence is set with ``setPersistence`` and specifies how a // user session is persisted on a device. We choose in session persistence by // default because many students will access the ROAR on shared devices in the // classroom. if (authPersistence === AuthPersistence.session) { yield (0, auth_1.setPersistence)(kit.auth, auth_1.browserSessionPersistence); } else if (authPersistence === AuthPersistence.local) { yield (0, auth_1.setPersistence)(kit.auth, auth_1.browserLocalPersistence); } else if (authPersistence === AuthPersistence.none) { yield (0, auth_1.setPersistence)(kit.auth, auth_1.inMemoryPersistence); } return kit; } }); exports.initializeFirebaseProject = initializeFirebaseProject; const emptyOrg = () => { return { current: [], all: [], dates: {}, }; }; exports.emptyOrg = emptyOrg; const emptyOrgList = () => { return { districts: [], schools: [], classes: [], groups: [], families: [], }; }; exports.emptyOrgList = emptyOrgList; /** * Merge new game parameters with old parameters with constraints * * The constraints are: * - no new parameters may be added, * - no old parameters may be removed, * - any parameters that have been changed must have had ``null`` values in ``oldParams`` * * @param oldParams - Old game parameters * @param newParams - New game parameters * @returns merged game parameters */ const mergeGameParams = (oldParams, newParams) => { let keysAdded = false; const customizer = (oldValue, newValue, key) => { if (oldValue === null) { return newValue; } if ((0, isEqual_1.default)(oldValue, newValue)) { return newValue; } if (oldValue === undefined && newValue !== undefined) { keysAdded = true; return newValue; } else { throw new Error(`Attempted to change previously non-null value with key ${key}`); } }; const merged = (0, mergeWith_1.default)(Object.assign({}, oldParams), newParams, customizer); const differentKeys = (0, difference_1.default)(Object.keys(merged), Object.keys(newParams)); if (!(0, isEmpty_1.default)(differentKeys)) { throw new Error(`Detected deleted keys: ${differentKeys.join(', ')}`); } return { keysAdded, merged, }; }; exports.mergeGameParams = mergeGameParams; const crc32String = (inputString) => { const modulo = (a, b) => { return a - Math.floor(a / b) * b; }; const toUint32 = (x) => { return modulo(x, Math.pow(2, 32)); }; return toUint32((0, crc_32_1.str)(inputString)).toString(16); }; exports.crc32String = crc32String; const treeTableFormat = (orgs, orgType, startIndex = 0) => { return orgs.map((element, index) => ({ key: (index + startIndex).toString(), data: Object.assign(Object.assign({}, element), { orgType }), })); }; const getTreeTableOrgs = (inputOrgs) => { const { districts = [], schools = [], classes = [], groups = [], families = [] } = inputOrgs; const ttDistricts = treeTableFormat(districts, 'district'); const ttSchools = treeTableFormat(schools, 'school'); const ttClasses = treeTableFormat(classes, 'class'); let topLevelOrgs = []; if (districts.length) { topLevelOrgs = ttDistricts; for (const _school of ttSchools) { const districtId = _school.data.districtId; const districtIndex = topLevelOrgs.findIndex((district) => district.data.id === districtId); // This will return all classes for this school and also remove them from the classes array. // At the end, we will add any left over classes as orphaned classes const classesForThisSchool = (0, remove_1.default)(ttClasses, (c) => c.data.schoolId === _school.data.id); if (districtIndex !== -1) { const _district = topLevelOrgs[districtIndex]; if (_district.children === undefined) { topLevelOrgs[districtIndex].children = [ Object.assign(Object.assign(Object.assign({}, _school), { key: `${_district.key}-0` }), (classesForThisSchool.length > 0 && { children: classesForThisSchool.map((element, index) => ({ key: `${_district.key}-0-${index}`, data: element.data, })), })), ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion topLevelOrgs[districtIndex].children.push(Object.assign(Object.assign(Object.assign({}, _school), { key: `${_district.key}-${_district.children.length}` }), (classesForThisSchool.length > 0 && { children: classesForThisSchool.map((element, index) => ({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion key: `${_district.key}-${_district.children.length}-${index}`, data: element.data, })), }))); } } else { topLevelOrgs.push(Object.assign(Object.assign(Object.assign({}, _school), { key: `${topLevelOrgs.length}` }), (classesForThisSchool.length > 0 && { children: classesForThisSchool.map((element, index) => ({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion key: `${topLevelOrgs.length}-${index}`, data: element.data, })), }))); } } // We have now gone through all of the schools and removed any classes that // belong to the supplied schools. If there are any schools left, they // should either be direct descendants of a district (rare) or they should // be at the top level. for (const _class of ttClasses) { const districtId = _class.data.districtId; const districtIndex = topLevelOrgs.findIndex((district) => district.data.id === districtId); if (districtIndex !== -1) { // Add this class as a direct descendant of the district const _district = topLevelOrgs[districtIndex]; if (_district.children === undefined) { topLevelOrgs[districtIndex].children = [ { key: `${_district.key}-0`, data: _class.data, }, ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion topLevelOrgs[districtIndex].children.push({ key: `${_district.key}-${_district.children.length}`, data: _class.data, }); } } else { // Add this class to the top-level orgs topLevelOrgs.push({ key: `${topLevelOrgs.length}`, data: _class.data, }); } } } else if (schools.length) { topLevelOrgs = ttSchools; for (const _class of ttClasses) { const schoolId = _class.data.schoolId; const schoolIndex = topLevelOrgs.findIndex((school) => school.data.id === schoolId); if (schoolIndex !== -1) { const _school = topLevelOrgs[schoolIndex]; if (_school.children === undefined) { topLevelOrgs[schoolIndex].children = [ Object.assign(Object.assign({}, _class), { key: `${_school.key}-0` }), ]; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion topLevelOrgs[schoolIndex].children.push(Object.assign(Object.assign({}, _class), { key: `${_school.key}-${_school.children.length}` })); } } else { topLevelOrgs.push(Object.assign(Object.assign({}, _class), { key: `${topLevelOrgs.length}` })); } } } else if (classes.length) { topLevelOrgs = ttClasses; } const ttGroups = treeTableFormat(groups, 'group', topLevelOrgs.length); topLevelOrgs.push(...ttGroups); const ttFamilies = treeTableFormat(families, 'family', topLevelOrgs.length); topLevelOrgs.push(...ttFamilies); return topLevelOrgs; }; exports.getTreeTableOrgs = getTreeTableOrgs; const chunkOrgLists = ({ orgs, chunkSize = 30 }) => { if (!orgs) return [undefined]; const orgPairs = (0, flatten_1.default)(Object.entries(orgs).map(([orgType, orgIds]) => { return orgIds.map((orgId) => [orgType, orgId]); })); if (orgPairs.length <= chunkSize) return [orgs]; const chunkedOrgs = (0, chunk_1.default)(orgPairs, chunkSize); return chunkedOrgs.map((chunk) => { const orgChunk = (0, exports.emptyOrgList)(); for (const [orgType, orgId] of chunk) { orgChunk[orgType].push(orgId); } return orgChunk; }); }; exports.chunkOrgLists = chunkOrgLists; const plurals = { group: 'groups', district: 'districts', school: 'schools', class: 'classes', family: 'families', administration: 'administrations', user: 'users', assignment: 'assignments', run: 'runs', trial: 'trials', }; const pluralizeFirestoreCollection = (singular) => { if (Object.values(plurals).includes(singular)) return singular; const plural = plurals[singular]; if (plural) return plural; throw new Error(`There is no plural Firestore collection for the ${singular}`); }; exports.pluralizeFirestoreCollection = pluralizeFirestoreCollection; const singularizeFirestoreCollection = (plural) => { if (Object.values((0, invert_1.default)(plurals)).includes(plural)) return plural; const singular = (0, invert_1.default)(plurals)[plural]; if (singular) return singular; throw new Error(`There is no Firestore collection ${plural}`); }; exports.singularizeFirestoreCollection = singularizeFirestoreCollection; const ALLOWED_EXTENSIONS = new Set(['.webm', '.mp4', '.wav', '.ogg', '.mkv', '.mp3']); const validateFileExtension = (filename) => { // Derive extension from the basename (after the last '/' or '\') and // ignore a leading dot in the basename (to match path.extname semantics). const base = filename.split(/[/\\]/).pop() || ''; const dotIndex = base.lastIndexOf('.'); const ext = dotIndex > 0 ? base.slice(dotIndex).toLowerCase() : ''; if (!ext || !ALLOWED_EXTENSIONS.has(ext)) { throw new Error(`Unsupported file type: "${ext || 'none'}". Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`); } }; exports.validateFileExtension = validateFileExtension; const sanitizeInput = (input) => { let sanitized = input .replace(/[\r\n]/g, '') // Remove CR/LF .replace(/[/\\]\.(?=[a-zA-Z])/g, '') // Remove periods after slash and before letter .replace(/[/\\]+/g, '') // Remove all remaining slashes .replace(/[?*[\]#&=\s\v\f]+/g, '') // Remove forbidden characters .replace(/\.{2,}/g, ''); // Remove consecutive periods // Truncate to 1024 bytes safely const encoder = new TextEncoder(); let encoded = encoder.encode(sanitized); if (encoded.length > 1024) { encoded = encoded.slice(0, 1024); sanitized = new TextDecoder().decode(encoded); } if (sanitized.length === 0) { throw new Error('Input must be at least 1 character long after sanitization.'); } return sanitized; }; exports.sanitizeInput = sanitizeInput;