@bdelab/roar-firekit
Version:
A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps
474 lines (473 loc) • 21.4 kB
JavaScript
;
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;