diff --git a/components/base/layouts/VViewWrapper.vue b/components/base/layouts/VViewWrapper.vue index 2f28501f..53fb9a22 100755 --- a/components/base/layouts/VViewWrapper.vue +++ b/components/base/layouts/VViewWrapper.vue @@ -261,5 +261,5 @@ const props = defineProps(); width: 100%; margin-left: 0; } -}*/ +} */ diff --git a/stores/darkmode.ts b/stores/darkmode.ts index 8461ac00..97050c78 100755 --- a/stores/darkmode.ts +++ b/stores/darkmode.ts @@ -9,7 +9,7 @@ * @see /src/components/partials/toolbars/Toolbar.vue */ -import { computed, watchEffect, ref } from "vue"; +import { computed, watchEffect } from "vue"; import { usePreferredDark, useStorage } from "@vueuse/core"; import { acceptHMRUpdate, defineStore } from "pinia"; import tinyColor from "tinycolor2"; @@ -26,7 +26,8 @@ export const initDarkmode = () => { watchEffect(() => { const body = document.documentElement; - if (darkmode.isDark) { + const isDark = darkmode.isDark as any; + if (isDark === true) { body.classList.add(DARK_MODE_BODY_CLASS); } else { body.classList.remove(DARK_MODE_BODY_CLASS); @@ -36,10 +37,10 @@ export const initDarkmode = () => { }; export const useDarkmode = defineStore("darkmode", () => { - let preferredDark; + const preferredDark = usePreferredDark(); const colorSchema = useStorage("color-schema", "auto"); - /*window.matchMedia("(prefers-color-scheme: dark)"); + /* window.matchMedia("(prefers-color-scheme: dark)"); window.addEventListener("change", (e) => { colorSchema.value = e.matches ? "dark" : "light"; });*/ @@ -60,23 +61,14 @@ export const useDarkmode = defineStore("darkmode", () => { const metaThemeColor = document.querySelector("meta[name=theme-color]"); const setHasDark = - colorSchema.value == "dark" || - (colorSchema.value == "auto" && preferredDark.value); + colorSchema.value === "dark" || + (colorSchema.value === "auto" && preferredDark.value === true); const colorVar = getComputedStyle(document.documentElement).getPropertyValue( setHasDark ? "--dark-sidebar-light-6" : "--white" ); const colorHex = tinyColor(colorVar).toHex(); - metaThemeColor.setAttribute("content", colorHex); - - if (typeof cordova !== "undefined") { - StatusBar.backgroundColorByHexString(colorHex); - - if (setHasDark) StatusBar.styleLightContent(); - else StatusBar.styleDefault(); - - NavigationBar.backgroundColorByHexString(colorHex, !setHasDark); - } + metaThemeColor?.setAttribute("content", colorHex); }; const onChange = (event: Event) => { @@ -88,15 +80,6 @@ export const useDarkmode = defineStore("darkmode", () => { isDark.value = !isDark.value; }; - if (typeof cordova !== "undefined") { - preferredDark = ref(false); - cordova.plugins.osTheme.getTheme().then((theme) => { - preferredDark.value = theme.isDark; - }); - } else { - preferredDark = usePreferredDark(); - } - return { isDark, onChange, diff --git a/stores/firebase.ts b/stores/firebase.ts index 38772c24..779e1bbf 100755 --- a/stores/firebase.ts +++ b/stores/firebase.ts @@ -1,15 +1,10 @@ // Import the functions you need from the SDKs you need -import type { Auth } from "firebase/auth"; import { getAuth, connectAuthEmulator } from "firebase/auth"; -import type { Firestore } from "firebase/firestore"; import { getFirestore, connectFirestoreEmulator } from "firebase/firestore"; -import type { Functions } from "firebase/functions"; import { getFunctions, connectFunctionsEmulator } from "firebase/functions"; -import type { FirebaseStorage } from "firebase/storage"; import { getStorage, connectStorageEmulator } from "firebase/storage"; import { getAnalytics, initializeAnalytics } from "firebase/analytics"; import { getPerformance, initializePerformance } from "firebase/performance"; -import type { Database } from "firebase/database"; import { getDatabase, connectDatabaseEmulator } from "firebase/database"; import { initializeApp } from "firebase/app"; import { getRemoteConfig, fetchAndActivate } from "firebase/remote-config"; diff --git a/stores/firestore/collection.ts b/stores/firestore/collection.ts index ad4a2541..64ee5e0e 100755 --- a/stores/firestore/collection.ts +++ b/stores/firestore/collection.ts @@ -1,3 +1,295 @@ -import { Query } from "./query"; +import type { + CollectionOptions as UseCollectionOption, + Collection as UseCollectionType, +} from "."; +import { newDoc, useCollection } from "."; +import type { Entity } from "./entity"; +import { onInitialize } from "./entity"; +import type { EntityMetaData } from "./entityMetadata"; +import { useFirebase } from "addeus-common-library/stores/firebase"; +import type { DocumentData, DocumentReference } from "firebase/firestore"; +import { collection, deleteDoc, doc, getDoc, setDoc } from "firebase/firestore"; +import { shallowReactive } from "vue"; +import { watchArray } from "@vueuse/core"; +import { securityCollectionCallbacks } from "./security/securityDecorators"; -export class Collection extends Query {} +export type FunctionPropertyNames = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; +}[keyof T]; + +export type NonFunctionProperties = { + [K in Exclude>]: T[K]; +}; + +export interface CollectionOptions { + namespace: string; + backlistFields?: Partial< + Omit, boolean>, "blacklistedProperties"> + >; +} + +export interface EntityInfo { + model: typeof Entity; + subPaths: { path: string; blacklistedProperties: string[] }[]; +} + +/** + * Map model to namespace of all entities + */ +export const entitiesInfos = new Map(); + +const onCollectionsInitialize = new Map void)[]>(); + +export function Collection(options: CollectionOptions) { + return function (target: any, propertyKey?: string) { + // On class + if (propertyKey === undefined) { + target.collectionName = options.namespace; + + // Associate namespace to model + entitiesInfos.set(target.collectionName, { + model: target, + subPaths: [{ path: target.collectionName, blacklistedProperties: [] }], + }); + onCollectionsInitialize.get(target.collectionName)?.forEach((init) => init()); + securityCollectionCallbacks.get(target.name)?.forEach((init) => init()); + } + // On property + else { + if (options.namespace === undefined) { + throw new Error("namespace is undefined"); + } + const namespace = options.namespace; + const onCollectionInitialize = () => { + const info = entitiesInfos.get(namespace); + if (info === undefined) { + throw new Error(`${namespace} info is undefined`); + } + + const blacklistedProperties = Object.entries(options.backlistFields ?? {}) + .filter(([, value]) => value) + .map(([key]) => key); + + // save propertyKey in subCollections of model + const subPathInfo = info.subPaths.find( + ({ path }) => path === propertyKey + ); + if (subPathInfo !== undefined) { + subPathInfo.blacklistedProperties.forEach((blacklistedProperty) => { + if (!blacklistedProperties.includes(blacklistedProperty)) { + throw new Error( + `property ${propertyKey} already exists, a subcollection name must have the same blacklistedProperties` + ); + } + }); + } else { + info.subPaths.push({ + path: propertyKey, + blacklistedProperties, + }); + } + + onInitialize(target, function (this: any, metadata: EntityMetaData) { + // tag property as collection property, used in Entity to save and parse this property + metadata.collectionProperties[propertyKey] = { + namespace, + blacklistedProperties, + }; + }); + }; + + // wait Collection decorator on model + const info = entitiesInfos.get(namespace); + if (info === undefined) { + const inits = onCollectionsInitialize.get(namespace); + if (inits === undefined) { + onCollectionsInitialize.set(namespace, [onCollectionInitialize]); + } else { + inits.push(onCollectionInitialize); + } + } else { + onCollectionInitialize(); + } + } + }; +} + +export class SubCollection { + private firestoreArray?: UseCollectionType; + private currentList = shallowReactive(new Array()); + private model?: typeof Entity; + private path?: string; + private initialized = false; + private stopWatch?: () => void; + private isFetched = false; + private new = false; + public blacklistedProperties: string[] = []; + + init( + model: typeof Entity, + path: string | undefined, + blacklistedProperties: string[] + ) { + this.model = model; + this.path = path; + this.blacklistedProperties = blacklistedProperties; + this.initialized = true; + this.new = path === undefined; + } + + setOptions(options?: Omit) { + if (!this.initialized) throw new Error(`property subcollection not initialized`); + if (this.model === undefined) + throw new Error(`model in property ${this.path} is undefined`); + if (this.path === undefined) + throw new Error(`path in property ${this.path} is undefined`); + + this.stopWatch?.(); + + const useCollectionOptions: UseCollectionOption = { + ...options, + path: this.path, + blacklistedProperties: this.blacklistedProperties, + }; + this.firestoreArray = useCollection(this.model, useCollectionOptions) as any; + + this.currentList.splice(0, this.currentList.length); + + this.stopWatch = watchArray( + this.firestoreArray!, + (value, oldValue, added: T[], removed: T[]) => { + added.forEach((a) => { + const alreadyInArray = this.currentList.some( + (entity) => entity.$getID() === a.$getID() + ); + if (!alreadyInArray) this.currentList.push(a); + }); + removed.forEach((r) => { + const index = this.currentList.findIndex( + (entity) => entity.$getID() === r.$getID() + ); + if (index !== -1) this.currentList.splice(index, 1); + }); + } + ); + this.isFetched = true; + } + + async exists(entity: Entity): Promise { + const id = entity.$getID(); + if (id === undefined) throw new Error("id is undefined"); + return await this.existsById(id); + } + + async existsById(id: string): Promise { + if (!this.initialized) throw new Error(`property subcollection not initialized`); + if (this.new) throw new Error(`property subcollection is new`); + const firebase = useFirebase(); + const snap = await getDoc(doc(firebase.firestore, `${this.path}/${id}`)); + return snap.exists(); + } + + get list() { + if (!this.isFetched && !this.new) this.setOptions(); + return this.currentList; + } + + get entityModel() { + return this.model; + } + + get isInitialized() { + return this.initialized; + } + + get isNew() { + return this.new; + } + + fetched() { + if (!this.isFetched) throw new Error(`property subcollection not initialized`); + if (this.firestoreArray === undefined) + throw new Error(`firestoreArray is undefined`); + return this.firestoreArray.fetched(); + } + + /** + * Get array modification between app datas and firestore datas + * @param appArray Current data in app (with new or deleted entities) + * @param firestoreArray Current data in firestore + * @returns {toDelete, toAdd} entities to delete and entities to add + */ + getArrayModification() { + const dbEntities: Entity[] = []; + if (this.firestoreArray) dbEntities.push(...this.firestoreArray); + const appEntities = [...this.currentList]; + + const toDelete = dbEntities.filter( + (f) => !appEntities.some((a) => a.$getID() === f.$getID()) + ); + const toAdd = appEntities.filter( + (a) => !dbEntities.some((f) => a.$getID() === f.$getID()) + ); + return { toDelete, toAdd }; + } + + newDoc(): T { + if (!this.isInitialized) + throw new Error(`property subcollection not initialized`); + if (this.model === undefined) throw new Error(`model is undefined`); + const entity = newDoc(this.model) as T; + entity.$getMetadata().saveNewDocPath = this.path; + this.currentList.push(entity); + return entity; + } +} + +/** + * Add and/or remove elements from firestore + * @param toRemove elements to remove + * @param toAdd elements to add + * @param path path of the collection + */ +export const updatePropertyCollection = async ( + toRemove: Array, + toAdd: Array, + path: string, + blacklistedProperties: string[] = [] +) => { + const firebase = useFirebase(); + + const collectionRef = collection(firebase.firestore, path); + const removePromises = toRemove.map(async (entity) => { + const id = entity.$getMetadata().reference!.id; + const docRef = doc(collectionRef, id); + await deleteDoc(docRef); + }); + const addPromises = toAdd.map(async (entity) => { + let id = entity.$getMetadata().reference?.id; + + // entity is already in collection + if (entity.$getMetadata().reference?.path === `${collectionRef.path}/${id}`) { + return; + } + + let docRef: DocumentReference | undefined; + if (id === undefined) { + // entity is new, save it before add to collection + await entity.$save(); + id = entity.$getMetadata().reference!.id; + docRef = entity.$getMetadata().reference!; + } else { + docRef = doc(collectionRef, id); + await setDoc(docRef, entity.$getPlain(), { merge: true }); + } + + // save sub collections of added entity recursively + const constructor = entity.constructor as typeof Entity; + const model = new constructor(); + const metadata = model.$getMetadata(); + metadata.setReference(docRef); + metadata.blacklistedProperties = blacklistedProperties; + await metadata.savePropertyCollections(entity); + }); + await Promise.all([...removePromises, ...addPromises]); +}; diff --git a/stores/firestore/entity.ts b/stores/firestore/entity.ts index 7228cd54..c88e193b 100644 --- a/stores/firestore/entity.ts +++ b/stores/firestore/entity.ts @@ -6,7 +6,6 @@ import { doc, setDoc, updateDoc, - FirestoreError, } from "firebase/firestore"; import { lowerCaseFirst } from "../../utils/string"; import { isReactive, markRaw, shallowReactive } from "vue"; @@ -137,19 +136,21 @@ export class Entity extends EntityBase { static collectionName: string; $setAndParseFromReference(querySnapshot: DocumentReference | DocumentSnapshot) { + const metadata = this.$getMetadata(); if (querySnapshot instanceof DocumentReference) { - this.$getMetadata().setReference(querySnapshot); + metadata.setReference(querySnapshot); } else if (querySnapshot instanceof DocumentSnapshot) { - this.$getMetadata().setReference(querySnapshot.ref); - if (!querySnapshot.exists()) return this.$getMetadata().markAsDeleted(); + metadata.setReference(querySnapshot.ref); + if (!querySnapshot.exists()) return metadata.markAsDeleted(); const data = querySnapshot.data(); - this.$getMetadata().previousOrigin = this.$getMetadata().origin = + metadata.previousOrigin = metadata.origin = typeof data === "object" && data !== null ? data : {}; - this.$getMetadata().emit("parse", this.$getMetadata().origin); + metadata.emit("parse", metadata.origin); - this.$getMetadata().isFullfilled = true; + metadata.isFullfilled = true; } + metadata.initSubCollections(); } static addMethod(name: string, callback: Function) { @@ -187,18 +188,21 @@ export class Entity extends EntityBase { if (isNew) { const firebase = useFirebase(); const docRef = doc( - collection(firebase.firestore, constructor.collectionName) + collection( + firebase.firestore, + $metadata.saveNewDocPath ?? constructor.collectionName + ) ); - await setDoc(docRef, raw); $metadata.setReference(docRef); + await setDoc(docRef, raw); } else if (Object.keys(raw).length > 0 && $metadata.reference !== null) { await updateDoc($metadata.reference, raw); } $metadata.previousOrigin = $metadata.origin; $metadata.origin = this.$getPlain(); } catch (err) { - if (err instanceof FirestoreError && err.code === "permission-denied") { + if (err instanceof FirebaseError && err.code === "permission-denied") { throw new Error( `You don't have permission to ${isNew ? "create" : "edit"} ${ $metadata.reference?.path @@ -209,9 +213,12 @@ export class Entity extends EntityBase { err.code === "auth/network-request-failed" ) return this.$save(); - throw err; } + + // save subcollections + await $metadata.savePropertyCollections(); + this.$getMetadata().emit("saved"); } @@ -220,7 +227,8 @@ export class Entity extends EntityBase { } async $delete() { - if (this.$getMetadata().reference) await deleteDoc(this.$getMetadata().reference); + const ref = this.$getMetadata().reference; + if (ref !== null) await deleteDoc(ref); this.$getMetadata().markAsDeleted(); this.$getMetadata().destroy(); } @@ -238,6 +246,6 @@ export class Entity extends EntityBase { } } -export function EntityArray(a: [] = []) { +export function EntityArray(a: any[] = []) { return shallowReactive(a); } diff --git a/stores/firestore/entityMetadata.ts b/stores/firestore/entityMetadata.ts index 3169be4b..94912417 100644 --- a/stores/firestore/entityMetadata.ts +++ b/stores/firestore/entityMetadata.ts @@ -1,7 +1,13 @@ import type { Entity } from "./entity"; import type { DocumentReference, DocumentSnapshot } from "firebase/firestore"; -import { getDoc, onSnapshot, onSnapshotsInSync } from "firebase/firestore"; +import { getDoc, onSnapshot } from "firebase/firestore"; import EventEmitter from "./event"; +import { SubCollection, entitiesInfos, updatePropertyCollection } from "./collection"; + +export interface CollectionProperties { + [key: string]: { namespace: string; blacklistedProperties: string[] }; +} + export class EntityMetaData extends EventEmitter { reference: DocumentReference | null = null; isFullfilled: boolean = false; @@ -14,6 +20,10 @@ export class EntityMetaData extends EventEmitter { unsuscribeSnapshot: Function | null = null; disableWatch: boolean = false; + blacklistedProperties: string[] = []; + collectionProperties: CollectionProperties = {}; + saveNewDocPath?: string; + constructor(entity: any) { super(); this.entity = entity; @@ -98,4 +108,84 @@ export class EntityMetaData extends EventEmitter { this.unsuscribeSnapshot?.(); }); } + + initSubCollections(isNew: boolean = false) { + // init subcollections + Object.entries(this.collectionProperties) + .filter(([propertyKey]) => !this.blacklistedProperties.includes(propertyKey)) + .map(([propertyKey, { namespace, blacklistedProperties }]) => { + if (!isNew && this.reference === null) + throw new Error("reference in metadata is null, new doc ?"); + + const info = entitiesInfos.get(namespace); + if (info === undefined) throw new Error(`${namespace} info is undefined`); + + const subCollection = (this.entity as any)[propertyKey]; + if (!(subCollection instanceof SubCollection)) + throw new Error(`${propertyKey} is not a SubCollection`); + subCollection.init( + info.model, + isNew ? undefined : `${this.reference!.path}/${propertyKey}`, + blacklistedProperties + ); + }); + } + + async savePropertyCollections(copyFrom?: Entity) { + const savePropertyCollectionPromises = Object.keys(this.collectionProperties).map( + async (propertyKey) => { + await this.savePropertyCollection(propertyKey, copyFrom); + } + ); + await Promise.all(savePropertyCollectionPromises); + } + + async savePropertyCollection(propertyKey: string, copyFrom?: Entity) { + if (this.reference === null) throw new Error("reference in metadata is null"); + + const constructor = this.entity.constructor as typeof Entity; + const info = entitiesInfos.get(constructor.collectionName); + if (info === undefined) + throw new Error(`${constructor.collectionName} info is undefined`); + + const propertySubCollection = (this.entity as any)[ + propertyKey + ] as SubCollection; + if (propertySubCollection.isNew) { + propertySubCollection.init( + propertySubCollection.entityModel!, + `${this.reference.path}/${propertyKey}`, + propertySubCollection.blacklistedProperties + ); + } + const copiedSubCollection = + copyFrom === undefined + ? undefined + : ((copyFrom as any)[propertyKey] as SubCollection); + // get blacklisted properties of entity collection + const parentBlacklistedProperties: string[] = []; + entitiesInfos.forEach((info) => { + info.subPaths.forEach((subPath) => { + if (subPath.path === this.reference?.parent.id) + parentBlacklistedProperties.push(...subPath.blacklistedProperties); + }); + }); + if (parentBlacklistedProperties.includes(propertyKey)) return; + + // elements changed in array, if copyFrom is defined, it's a new entity, all elements are added from copyFrom + const { toDelete, toAdd } = + copiedSubCollection !== undefined + ? { + toDelete: [], + toAdd: [...copiedSubCollection.list], + } + : propertySubCollection.getArrayModification(); + + await updatePropertyCollection( + toDelete, + toAdd, + `${this.reference.path}/${propertyKey}`, + parentBlacklistedProperties + ); + } } diff --git a/stores/firestore/index.ts b/stores/firestore/index.ts index 99efca91..dcd944b6 100644 --- a/stores/firestore/index.ts +++ b/stores/firestore/index.ts @@ -5,11 +5,22 @@ import type { WhereFilterOp, QueryConstraint, OrderByDirection, - DocumentSnapshot, QueryFilterConstraint, FieldPath, } from "firebase/firestore"; -import { where, orderBy, collection, doc, or, and } from "firebase/firestore"; +import { + where, + orderBy, + collection, + doc, + or, + and, + DocumentSnapshot, + DocumentReference, + query, + collectionGroup, + getDocs, +} from "firebase/firestore"; import { useFirebase } from "../firebase"; import type { MaybeRef } from "@vueuse/core"; import { until } from "@vueuse/core"; @@ -35,6 +46,8 @@ export interface CollectionOptions { limit?: MaybeRef; search?: MaybeRef; compositeConstraint?: MaybeRef; + path?: string; + blacklistedProperties?: string[]; } const cachedEntities: { [key: string]: { usedBy: number; entity: any } } = {}; @@ -115,7 +128,10 @@ export function useCollection( }${collectionModel.collectionName}` ); - const collectionRef = collection(firebase.firestore, collectionModel.collectionName); + const collectionRef = collection( + firebase.firestore, + options.path === undefined ? collectionModel.collectionName : options.path + ); entities.isUpdating = true; @@ -151,9 +167,14 @@ export function useCollection( constraints, entities, (doc: DocumentSnapshot) => { - return transform(doc, collectionModel, (callback) => { - onDestroy.push(callback); - }); + return transform( + doc, + collectionModel, + (callback) => { + onDestroy.push(callback); + }, + options.blacklistedProperties + ); }, collectionRef, search, @@ -167,15 +188,20 @@ export function useCollection( constraints, entities, (doc: DocumentSnapshot) => { - return transform(doc, collectionModel, (callback) => { - onDestroy.push(callback); - }); + return transform( + doc, + collectionModel, + (callback) => { + onDestroy.push(callback); + }, + options.blacklistedProperties + ); }, collectionRef ); } - let limit = 10; + let limit = -1; if (isRef(options.limit) && typeof options.limit.value === "number") limit = options.limit.value; else if (typeof options.limit === "number") limit = options.limit; @@ -260,6 +286,7 @@ export function useDoc( export function newDoc(collectionModel: T): InstanceType { const entity = new collectionModel(); + entity.$getMetadata().initSubCollections(true); (getCurrentScope() ? onScopeDispose : () => {})(() => { if (typeof entity.$getID !== "function") return; @@ -353,14 +380,22 @@ export async function findDoc( * @returns */ function transform( - doc: DocumentSnapshot, + doc: DocumentSnapshot | DocumentReference, Model: T, - onDisposed: (callback: () => void) => void + onDisposed: (callback: () => void) => void, + blacklistedProperties: string[] = [] ): InstanceType { - const cachedIdEntity = `${Model.collectionName}/${doc.id}`; + let path: string | undefined = undefined; + if (doc instanceof DocumentReference) { + path = doc.path; + } else if (doc instanceof DocumentSnapshot) { + path = doc.ref.path; + } + const cachedIdEntity = path ?? `${Model.collectionName}/${doc.id}`; if (cachedEntities[cachedIdEntity] === undefined) { const model = new Model(); model.$setAndParseFromReference(doc); + model.$getMetadata().blacklistedProperties = blacklistedProperties; cachedEntities[cachedIdEntity] = { entity: model, usedBy: 0, @@ -381,6 +416,41 @@ function transform( return cachedEntities[cachedIdEntity].entity; } +export const useParentOfCollectionGroup = ( + model: typeof Entity, + collectionGroupName: string, + wheres: MaybeRef +) => { + const firebase = useFirebase(); + + const workspaceRefs = shallowReactive(new Collection()); + + if (isRef(wheres)) { + watch( + wheres, + async (value) => { + const whereConstraints: QueryConstraint[] = transformWheres(value); + const groupQuery = query( + collectionGroup(firebase.firestore, collectionGroupName), + ...whereConstraints + ); + const groupSnapshot = await getDocs(groupQuery); + const newWorkspaceRefs = groupSnapshot.docs + .filter( + (doc) => + doc.ref.parent.parent !== null && + doc.ref.parent.parent?.parent?.path === model.collectionName + ) + .map((doc) => doc.ref.parent.parent!); + workspaceRefs.splice(0, workspaceRefs.length, ...newWorkspaceRefs); + }, + { immediate: true } + ); + } + + return workspaceRefs; +}; + export function clearCache() { for (const cachedIdEntity in cachedEntities) { cachedEntities[cachedIdEntity].entity.$getMetadata().destroy(); diff --git a/stores/firestore/security/cloudFunctionModel.ts b/stores/firestore/security/cloudFunctionModel.ts new file mode 100644 index 00000000..4eb91b03 --- /dev/null +++ b/stores/firestore/security/cloudFunctionModel.ts @@ -0,0 +1,113 @@ +import type { EntityInfo } from "../collection"; +import { entitiesInfos } from "../collection"; +import type { CollectionProperties } from "../entityMetadata"; + +interface Model { + [key: string]: Collection[]; +} + +interface Collection { + path: string; + namespace: string; + group: string; + parentNamespace?: string; + parentGroupsWithThisCollection: string[]; + blacklistedSubPaths: string[]; +} + +export const createCloudFunctionModel = (): string => { + const collections = getCollections(); + const model = collections.reduce((acc, collection) => { + if (acc[collection.namespace] === undefined) { + acc[collection.namespace] = [collection]; + } else { + acc[collection.namespace].push(collection); + } + return acc; + }, {} as Model); + return JSON.stringify(model, null, 4); +}; + +const getCollectionProperties = (entitiesInfo: EntityInfo): CollectionProperties => { + const entity = new entitiesInfo.model(); + const metadata = entity.$getMetadata(); + return metadata.collectionProperties; +}; + +const getNamespaceCollections = ( + namespace: string, + path?: string, + blacklistedSubPaths: string[] = [] +): Collection[] => { + const collections: Collection[] = []; + if (path === undefined) { + const rootPath = `${namespace}/{${namespace}Id}`; + collections.push({ + path: rootPath, + namespace, + group: namespace, + parentGroupsWithThisCollection: [], + blacklistedSubPaths, + }); + path = rootPath; + } + const entitiesInfo = entitiesInfos.get(namespace); + if (entitiesInfo === undefined) { + throw new Error(`Collection ${namespace} is not defined`); + } + const collectionNamespaces = Object.entries(getCollectionProperties(entitiesInfo)); + const collectionsPaths = collectionNamespaces + .filter(([name]) => !blacklistedSubPaths.includes(name)) + .map(([name, group]): Collection[] => { + const collectionPath = `${path}/${name}/{${name}Id}`; + const collectionNamespace = `${group.namespace}`; + const collectionBlacklistedSubPaths = group.blacklistedProperties as + | string[] + | undefined; + const subPaths = getNamespaceCollections( + collectionNamespace, + collectionPath, + collectionBlacklistedSubPaths + ); + + return [ + { + path: collectionPath, + namespace: collectionNamespace, + group: name, + parentGroupsWithThisCollection: [], + parentNamespace: namespace, + blacklistedSubPaths: collectionBlacklistedSubPaths ?? [], + }, + ...subPaths, + ]; + }); + collections.push(...collectionsPaths.flat()); + return collections; +}; + +const getCollections = (): Collection[] => { + const model: Model = {}; + entitiesInfos.forEach((_, rootCollection) => { + model[rootCollection] = getNamespaceCollections(rootCollection); + }); + + const collections: Collection[] = Object.entries(model) + .map(([name]) => { + return getNamespaceCollections(name); + }) + .flat(); + + collections.forEach((collection) => { + const parentGroups = collections.filter( + (parentCollection) => + parentCollection.namespace === collection.parentNamespace && + !parentCollection.blacklistedSubPaths.includes(collection.group) + ); + collection.parentGroupsWithThisCollection = parentGroups.map( + (parentCollection) => parentCollection.group + ); + }); + + return collections; +}; diff --git a/stores/firestore/security/rules.ts b/stores/firestore/security/rules.ts new file mode 100644 index 00000000..e5dfabbf --- /dev/null +++ b/stores/firestore/security/rules.ts @@ -0,0 +1,161 @@ +import { entitiesInfos } from "../collection"; +import type { Entity } from "../entity"; +import type { CollectionProperties } from "../entityMetadata"; +import type { SecurityInfo } from "./securityDecorators"; +import { rootCollections, securityInfos } from "./securityDecorators"; + +export const createSecurityRules = (): string => { + return ` +// generated by script +rules_version = "2"; +service cloud.firestore { + match /databases/{database}/documents { + ${rootCollections + .map((rootCollection) => { + return createCollectionRules(rootCollection); + }) + .join("")} + } +} + `; +}; + +const createCollectionRules = ( + collectionName: string, + modelNamespace?: string, + blacklistedProperties?: string[], + parentModelNamespace?: string +): string => { + if (modelNamespace === undefined) modelNamespace = collectionName; + const entitiesInfo = entitiesInfos.get(modelNamespace); + if (entitiesInfo === undefined) { + throw new Error(`Collection ${modelNamespace} is not defined`); + } + const { documentProperties, collectionProperties } = getProperties( + entitiesInfo.model + ); + const collectionSecurityOptions = securityInfos.get( + parentModelNamespace === undefined + ? collectionName + : `${parentModelNamespace}/${collectionName}` + ); + + const rules: string[] = []; + rules.push(createReadRule("get", collectionSecurityOptions?.security?.get)); + rules.push(createReadRule("list", collectionSecurityOptions?.security?.list)); + rules.push( + createWriteRule( + "create", + collectionSecurityOptions?.security?.create, + documentProperties, + collectionSecurityOptions + ) + ); + rules.push( + createWriteRule( + "update", + collectionSecurityOptions?.security?.update, + documentProperties, + collectionSecurityOptions + ) + ); + rules.push( + createWriteRule( + "delete", + collectionSecurityOptions?.security?.delete, + [], + collectionSecurityOptions + ) + ); + + const subCollectionsRules = Object.entries(collectionProperties) + .filter( + ([collectionProperty]) => !blacklistedProperties?.includes(collectionProperty) + ) + .map(([collectionProperty, { namespace, blacklistedProperties }]) => { + return createCollectionRules( + collectionProperty, + namespace, + blacklistedProperties, + modelNamespace + ); + }); + + return ` + // ${modelNamespace} + match /${collectionName}/{${modelNamespace}Id} { + ${rules.filter((rule) => rule !== "").join("\n ")} + ${subCollectionsRules.join("").replaceAll("\n", "\n ")} + } +`; +}; + +const createReadRule = (rule: "get" | "list", ruleContent: string | undefined) => { + if (ruleContent === undefined || ruleContent === "false") { + return ""; + } + return `allow ${rule}: if ${ruleContent};`; +}; + +const createWriteRule = ( + rule: "create" | "update" | "delete", + ruleContent: string | undefined, + documentProperties: string[], + collectionSecurityOptions: SecurityInfo | undefined +) => { + if (ruleContent === undefined || ruleContent === "false") { + return ""; + } + if (ruleContent === "true") { + return `allow ${rule}: if true;`; + } + const documentPropertiesCheck = + documentProperties.length > 0 + ? `${createModelWritePropertiesRules(documentProperties)} && ` + : ""; + return `allow ${rule}: if ${documentPropertiesCheck}(${ruleContent}) ${createPropertiesRules( + documentProperties, + collectionSecurityOptions, + rule + )};`; +}; + +const createModelWritePropertiesRules = (properties: string[]): string => { + return `request.resource.data.keys().hasOnly(["${properties.join('","')}"])`; +}; + +const getProperties = ( + model: typeof Entity +): { + documentProperties: string[]; + collectionProperties: CollectionProperties; +} => { + const entity = new model(); + const metadata = entity.$getMetadata(); + const documentProperties = Object.getOwnPropertyNames(metadata.properties); + documentProperties.push("originalId"); + return { + documentProperties, + collectionProperties: metadata.collectionProperties, + }; +}; + +const createPropertiesRules = ( + properties: string[], + modelSecurity: SecurityInfo | undefined, + operation: "create" | "update" | "delete" +): string => { + const propertiesRules = properties.filter( + (property) => modelSecurity?.properties?.[property]?.[operation] !== undefined + ); + if (propertiesRules.length === 0) { + return ""; + } + return `&& (${propertiesRules + .map((property) => { + const rule = modelSecurity?.properties?.[property][operation]; + if (rule === "false") return `request.resource.data.${property} == null`; + return `(request.resource.data.${property} == null || ${modelSecurity?.properties?.[property][operation]})`; + }) + .join(" && ")})`; +}; diff --git a/stores/firestore/security/securityDecorators.ts b/stores/firestore/security/securityDecorators.ts new file mode 100644 index 00000000..2242a189 --- /dev/null +++ b/stores/firestore/security/securityDecorators.ts @@ -0,0 +1,120 @@ +interface WriteOperation { + create?: string; + update?: string; + delete?: string; +} + +interface ReadOperation { + get?: string; + list?: string; +} + +interface SecurityOptions extends WriteOperation, ReadOperation {} + +export interface SecurityInfo { + properties?: { + [key: string]: WriteOperation; + }; + security?: SecurityOptions; +} + +export const securityInfos = new Map(); +export const rootCollections: string[] = []; +export const securityCollectionCallbacks = new Map void)[]>(); +const securityPropertyCallbacks = new Map void)[]>(); + +export function SecurityEntity(options: SecurityOptions) { + return function (target: any, propertyKey?: string) { + // on class + if (propertyKey === undefined) { + const init = () => { + const key = target.collectionName; + if (securityInfos.has(key)) { + throw new Error(`Security already defined for ${key}`); + } + securityInfos.set(key, { security: options }); + if (securityPropertyCallbacks.has(target.name)) { + securityPropertyCallbacks.get(target.name)!.forEach((callback) => { + callback(); + }); + } + rootCollections.push(key); + }; + if (target.collectionName === undefined) { + if (!securityCollectionCallbacks.has(target.name)) { + securityCollectionCallbacks.set(target.name, []); + } + securityCollectionCallbacks.get(target.name)!.push(init); + } else { + init(); + } + } else { + throw new Error(`SecurityCollection is not allowed on property`); + } + }; +} +export function SecuritySubCollection(options: SecurityOptions) { + return function (target: any, propertyKey?: string) { + // on class + if (propertyKey === undefined) { + throw new Error(`SecuritySubCollection is not allowed on class`); + } + // on property + else { + const init = () => { + securityInfos.set(`${target.constructor.collectionName}/${propertyKey}`, { + security: options, + }); + }; + if (target.collectionName === undefined) { + if (!securityPropertyCallbacks.has(target.constructor.name)) { + securityPropertyCallbacks.set(target.constructor.name, []); + } + securityPropertyCallbacks.get(target.constructor.name)!.push(init); + } else { + init(); + } + } + }; +} +export function SecurityProperty(options: SecurityOptions) { + return function (target: any, propertyKey?: string) { + // on class + if (propertyKey === undefined) { + throw new Error(`Security is not allowed on class`); + } + // on property + else { + const init = () => { + const notAllowed: string[] = []; + if (options.list !== undefined) { + notAllowed.push("list"); + } + if (options.get !== undefined) { + notAllowed.push("get"); + } + if (notAllowed.length > 0) { + throw new Error( + `Security ${notAllowed.join(", ")} is not allowed on property` + ); + } + const securityInfo = securityInfos.get(target.constructor.collectionName); + if (securityInfo === undefined) { + throw new Error( + `Security is not defined on ${target.constructor.collectionName}` + ); + } + securityInfo.properties = securityInfo.properties ?? {}; + securityInfo.properties[propertyKey] = options; + }; + if (target.collectionName === undefined) { + if (!securityPropertyCallbacks.has(target.constructor.name)) { + securityPropertyCallbacks.set(target.constructor.name, []); + } + securityPropertyCallbacks.get(target.constructor.name)!.push(init); + } else { + init(); + } + } + }; +} diff --git a/stores/firestore/var.ts b/stores/firestore/var.ts index 3c409e2e..a1092b74 100644 --- a/stores/firestore/var.ts +++ b/stores/firestore/var.ts @@ -174,6 +174,12 @@ export function Var(type: any) { }); metadata.on("parse", (raw: any, forceAll: boolean = false) => { + if ( + metadata.blacklistedProperties?.length > 0 && + metadata.blacklistedProperties.includes(name) + ) { + return; + } if ( typeof raw[name] === "object" && isEntityClass(type) && diff --git a/tsconfig.json b/tsconfig.json index 8d1ba5c1..38c92150 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,7 +41,7 @@ "include": [ "node_modules/addeus-common-library/**/*.ts", "composable/**.ts", - "stores/**.ts", + "stores/**/*.ts", "components/**/*.ts", "components/**/*.vue", "layouts/**/*.vue", diff --git a/utils/array.ts b/utils/array.ts index f8d4fff1..cc076dfd 100755 --- a/utils/array.ts +++ b/utils/array.ts @@ -49,9 +49,9 @@ export function isEnum(enumeration: any) { return false; } if (!isNaN(Number(keys[0]))) { - return enumeration[Number(keys[0])] != undefined; + return enumeration[Number(keys[0])] !== undefined; } else if (typeof keys[0] === "string") { - return enumeration[keys[0]] != undefined; + return enumeration[keys[0]] !== undefined; } return false; } diff --git a/utils/observer.ts b/utils/observer.ts index b7125f54..a7eb29fd 100755 --- a/utils/observer.ts +++ b/utils/observer.ts @@ -1,13 +1,14 @@ import sleep from "./sleep"; import { isVisible, isHidden } from "./element"; + export async function waitForElementPresent(selector, parent = document.body) { await new Promise((resolve) => { - if (parent.querySelector(selector)) { + if (parent.querySelector(selector) !== null) { return resolve(parent.querySelector(selector)); } const observer = new MutationObserver(() => { - if (parent.querySelector(selector)) { + if (parent.querySelector(selector) !== null) { resolve(parent.querySelector(selector)); observer.disconnect(); } @@ -28,13 +29,13 @@ export async function waitForElementLoaded(element) { await Promise.all( Array.prototype.map.call(elements, async (element) => { if (element.tagName.toLowerCase() === "img") { - if (element.complete && element.naturalHeight !== 0) return; + if (element.complete === true && element.naturalHeight !== 0) return; return new Promise((resolve, reject) => { element.addEventListener("load", resolve); element.addEventListener("error", reject); }); } else if (element.tagName.toLowerCase() === "iframe") { - if (element.contentDocument.readyState == "complete") { + if (element.contentDocument.readyState === "complete") { await sleep(100); return; } @@ -69,7 +70,7 @@ export async function waitForElementHidden(element: Element): Promise { return new Promise((resolve) => { const intersectionObserver = new IntersectionObserver( () => { - if (isHidden(element)) { + if (isHidden(element) === true) { intersectionObserver.disconnect(); resolve(element); } @@ -100,7 +101,7 @@ function setTransition(element, value) { function getTransition(element) { let value = null; transitionPropertyNames.forEach((property) => { - if (element.style[property]) { + if (element.style[property] !== undefined) { value = element.style[property]; } }); @@ -109,7 +110,7 @@ function getTransition(element) { export async function waitTransition(element, styles, duration, easing) { let oldTransition; - if (duration) { + if (typeof duration === "number" && typeof easing === "string") { oldTransition = getTransition(element); const transition = Object.keys(styles) .map((key) => { @@ -120,9 +121,9 @@ export async function waitTransition(element, styles, duration, easing) { } await Promise.all( Object.keys(styles).map((key) => { - if (element.style[key] == styles[key]) return; + if (element.style[key] === styles[key]) return; - return new Promise((resolve) => { + return new Promise((resolve) => { const transitionEnded = (e) => { if (e.propertyName !== key) return; element.removeEventListener("transitionend", transitionEnded); @@ -143,7 +144,7 @@ export async function waitTransition(element, styles, duration, easing) { }); }) ); - if (duration) { + if (typeof duration === "number") { setTransition(element, oldTransition); } }