import { $mobx, action, observable, runInAction, trace } from 'mobx'; import { computedFn } from 'mobx-utils'; import { ClientUtils, returnZero } from '../ClientUtils'; import { DocServer } from '../client/DocServer'; import { SerializationHelper } from '../client/util/SerializationHelper'; import { UndoManager } from '../client/util/UndoManager'; import { Doc, DocListCast, FieldType, FieldResult, HierarchyMapping, ReverseHierarchyMap, StrListCast, aclLevel, updateCachedAcls } from './Doc'; import { AclAdmin, AclAugment, AclEdit, AclPrivate, DirectLinks, DocAcl, DocData, DocLayout, FieldKeys, ForceServerWrite, Height, Initializing, SelfProxy, UpdatingFromServer, Width } from './DocSymbols'; import { FieldChanged, Id, Parent, ToValue } from './FieldSymbols'; import { List } from './List'; import { ObjectField } from './ObjectField'; import { PrefetchProxy, ProxyField } from './Proxy'; import { RefField } from './RefField'; import { RichTextField } from './RichTextField'; import { SchemaHeaderField } from './SchemaHeaderField'; import { ComputedField } from './ScriptField'; import { DocCast, ScriptCast, StrCast } from './Types'; /** * These are the various levels of access a user can have to a document. * * Admin: a user with admin access to a document can remove/edit that document, add/remove/edit annotations (depending on permissions), as well as change others' access rights to that document. * Edit: a user with edit access to a document can remove/edit that document, add/remove/edit annotations (depending on permissions), but not change any access rights to that document. * Add: a user with add access to a document can augment documents/annotations to that document but cannot edit or delete anything. * View: a user with view access to a document can only view it - they cannot add/remove/edit anything. * None: the document is not shared with that user. * Unset: Remove a sharing permission (eg., used ) */ export enum SharingPermissions { Admin = 'Admin', Edit = 'Edit', Augment = 'Augment', View = 'View', None = 'Not-Shared', } function _readOnlySetter(): never { throw new Error("Documents can't be modified in read-only mode"); } // eslint-disable-next-line prefer-const let tracing = false; export function TraceMobx() { tracing && trace(); } export const _propSetterCB = new Map void) | undefined>(); const _setterImpl = action((target: any, prop: string | symbol | number, valueIn: any, receiver: any): boolean => { if (SerializationHelper.IsSerializing() || typeof prop === 'symbol') { target[prop] = valueIn; return true; } let value = valueIn?.[SelfProxy] ?? valueIn; // convert any Doc type values to Proxy's const curValue = target.__fieldTuples[prop]; if (curValue === value || (curValue instanceof ProxyField && value instanceof RefField && curValue.fieldId === value[Id])) { // TODO This kind of checks correctly in the case that curValue is a ProxyField and value is a RefField, but technically // curValue should get filled in with value if it isn't already filled in, in case we fetched the referenced field some other way return true; } if (value instanceof RefField) { value = new ProxyField(value); } if (value instanceof ObjectField) { if (value[Parent] && value[Parent] !== receiver && !(value instanceof PrefetchProxy)) { throw new Error("Can't put the same object in multiple documents at the same time"); } value[Parent] = receiver; // eslint-disable-next-line no-use-before-define value[FieldChanged] = containedFieldChangedHandler(receiver, prop, value); } if (curValue instanceof ObjectField) { delete curValue[Parent]; delete curValue[FieldChanged]; } if (typeof prop === 'string' && _propSetterCB.has(prop)) _propSetterCB.get(prop)!(target[SelfProxy], value); // eslint-disable-next-line no-use-before-define const effectiveAcl = GetEffectiveAcl(target); const writeMode = DocServer.getFieldWriteMode(prop as string); const fromServer = target[UpdatingFromServer]; const sameAuthor = fromServer || receiver.author === ClientUtils.CurrentUserEmail(); const writeToDoc = sameAuthor || effectiveAcl === AclEdit || effectiveAcl === AclAdmin || writeMode === DocServer.WriteMode.Playground || writeMode === DocServer.WriteMode.LivePlayground || (effectiveAcl === AclAugment && value instanceof RichTextField); const writeToServer = !DocServer.Control.isReadOnly() && // (sameAuthor || effectiveAcl === AclEdit || effectiveAcl === AclAdmin || (effectiveAcl === AclAugment && value instanceof RichTextField)); if (writeToDoc) { if (value === undefined) { target[FieldKeys] && delete target[FieldKeys][prop]; // Lists don't have a FieldKeys field delete target.__fieldTuples[prop]; } else { // bcz: uncomment to see if server is being updated // console.log(prop + ' = ' + value + '(' + curValue + ')'); target[FieldKeys] && (target[FieldKeys][prop] = true); // Lists don't have a FieldKeys field target.__fieldTuples[prop] = value; } if (writeToServer) { // prettier-ignore if (value === undefined) (target as Doc|ObjectField)[FieldChanged]?.(undefined, { $unset: { ['fields.' + prop]: '' } }); else (target as Doc|ObjectField)[FieldChanged]?.(undefined, { $set: { ['fields.' + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) :value}}); if (prop === 'author' || prop.toString().startsWith('acl_')) updateCachedAcls(target); } else { DocServer.registerDocWithCachedUpdate(receiver, prop as string, curValue); } !receiver[Initializing] && !StrListCast(receiver.undoIgnoreFields).includes(prop.toString()) && (!receiver[UpdatingFromServer] || receiver[ForceServerWrite]) && UndoManager.AddEvent( { redo: () => { receiver[prop] = value; }, undo: () => { const wasUpdate = receiver[UpdatingFromServer]; const wasForce = receiver[ForceServerWrite]; receiver[ForceServerWrite] = true; // needed since writes aren't propagated to server if UpdatingFromServerIsSet receiver[UpdatingFromServer] = true; // needed if the event caused ACL's to change such that the doc is otherwise no longer editable. receiver[prop] = curValue; receiver[ForceServerWrite] = wasForce; receiver[UpdatingFromServer] = wasUpdate; }, prop: prop?.toString(), }, value ); return true; } return true; }); let _setter: (target: any, prop: string | symbol | number, value: any, receiver: any) => boolean = _setterImpl; export function makeReadOnly() { _setter = _readOnlySetter; } export function makeEditable() { _setter = _setterImpl; } export function normalizeEmail(email: string) { return email.replace(/\./g, '__'); } export function denormalizeEmail(email: string) { return email.replace(/__/g, '.'); } // return acl from cache or cache the acl and return. // eslint-disable-next-line no-use-before-define const getEffectiveAclCache = computedFn((target: any, user?: string) => getEffectiveAcl(target, user), true); /** * Calculates the effective access right to a document for the current user. */ export function GetEffectiveAcl(target: any, user?: string): symbol { if (!target) return AclPrivate; if (target[UpdatingFromServer] || ClientUtils.CurrentUserEmail() === 'guest') return AclAdmin; return getEffectiveAclCache(target, user); // all changes received from the server must be processed as Admin. return this directly so that the acls aren't cached (UpdatingFromServer is not observable) } export function GetPropAcl(target: any, prop: string | symbol | number) { if (typeof prop === 'symbol' || target[UpdatingFromServer]) return AclAdmin; // requesting the UpdatingFromServer prop or AclSym must always go through to keep the local DB consistent if (prop && DocServer.IsPlaygroundField(prop.toString())) return AclEdit; // playground props are always editable return GetEffectiveAcl(target); } const cachedGroups = observable([] as string[]); const getCachedGroupByNameCache = computedFn((name: string) => cachedGroups.includes(name), true); export function GetCachedGroupByName(name: string) { return getCachedGroupByNameCache(name); } export function SetCachedGroups(groups: string[]) { runInAction(() => cachedGroups.push(...groups)); } function getEffectiveAcl(target: any, user?: string): symbol { const targetAcls = target[DocAcl]; if (targetAcls?.acl_Me === AclAdmin || GetCachedGroupByName('Admin')) return AclAdmin; const userChecked = user || ClientUtils.CurrentUserEmail(); // if the current user is the author of the document / the current user is a member of the admin group if (targetAcls && Object.keys(targetAcls).length) { let effectiveAcl = AclPrivate; Object.entries(targetAcls).forEach(([key, value]) => { // there are issues with storing fields with . in the name, so they are replaced with _ during creation // as a result we need to restore them again during this comparison. const entity = denormalizeEmail(key.substring(4)); // an individual or a group if (GetCachedGroupByName(entity) || userChecked === entity || entity === 'Me') { if (HierarchyMapping.get(value as symbol)!.level > HierarchyMapping.get(effectiveAcl)!.level) { effectiveAcl = value as symbol; } } }); return DocServer?.Control?.isReadOnly?.() && HierarchyMapping.get(effectiveAcl)!.level < aclLevel.editable ? AclEdit : effectiveAcl; } // authored documents are private until an ACL is set. const targetAuthor = target.__fieldTuples?.author || target.author; // target may be a Doc of Proxy, so check __fieldTuples.author and .author if (targetAuthor && targetAuthor !== userChecked) return AclPrivate; return AclAdmin; } /** * Recursively distributes the access right for a user across the children of a document and its annotations. * @param key the key storing the access right (e.g. acl_groupname) * @param acl the access right being stored (e.g. "Can Edit") * @param target the document on which this access right is being set * @param visited list of Doc's already distributed to. * @param allowUpgrade whether permissions can be made less restrictive * @param layoutOnly just sets the layout doc's ACL (unless the data doc has no entry for the ACL, in which case it will be set as well) */ // eslint-disable-next-line default-param-last export function distributeAcls(key: string, acl: SharingPermissions, target: Doc, visited: Doc[] = [], allowUpgrade?: boolean, layoutOnly = false) { const selfKey = `acl_${normalizeEmail(ClientUtils.CurrentUserEmail())}`; if (!target || visited.includes(target) || key === selfKey) return; visited.push(target); let dataDocChanged = false; const dataDoc = target[DocData]; const curVal = ReverseHierarchyMap.get(StrCast(dataDoc[key]))?.level ?? 0; const aclVal = ReverseHierarchyMap.get(acl)?.level ?? 0; if (!layoutOnly && dataDoc && (allowUpgrade !== false || !dataDoc[key] || curVal > aclVal)) { // propagate ACLs to links, children, and annotations dataDoc[DirectLinks].forEach(link => distributeAcls(key, acl, link, visited, !!allowUpgrade)); DocListCast(dataDoc[Doc.LayoutFieldKey(dataDoc)]).forEach(d => { distributeAcls(key, acl, d, visited, !!allowUpgrade); d !== d[DocData] && distributeAcls(key, acl, d[DocData], visited, !!allowUpgrade); }); DocListCast(dataDoc[Doc.LayoutFieldKey(dataDoc) + '_annotations']).forEach(d => { distributeAcls(key, acl, d, visited, !!allowUpgrade); d !== d[DocData] && distributeAcls(key, acl, d[DocData], visited, !!allowUpgrade); }); Object.keys(target) // share expanded layout templates (eg, for presElementBox'es ) .filter(lkey => lkey.includes('layout[') && DocCast(target[lkey])) .forEach(lkey => distributeAcls(key, acl, DocCast(target[lkey]), visited, !!allowUpgrade)); if (GetEffectiveAcl(dataDoc) === AclAdmin) { dataDoc[key] = acl; dataDocChanged = true; } } let layoutDocChanged = false; // determines whether fetchProto should be called or not (i.e. is there a change that should be reflected in target[AclSym]) // if it is inheriting from a collection, it only inherits if A) allowUpgrade is set B) the key doesn't already exist or c) the right being inherited is more restrictive if (GetEffectiveAcl(target) === AclAdmin && (allowUpgrade || !Doc.GetT(target, key, 'boolean', true) || ReverseHierarchyMap.get(StrCast(target[key]))!.level > aclVal)) { target[key] = acl; layoutDocChanged = true; if (dataDoc[key] === undefined) dataDoc[key] = acl; } layoutDocChanged && updateCachedAcls(target); // updates target[AclSym] when changes to acls have been made dataDocChanged && updateCachedAcls(dataDoc); } /** * Copies parent's acl fields to the child */ export function inheritParentAcls(parent: Doc, child: Doc, layoutOnly: boolean) { [...Object.keys(parent), ...(ClientUtils.CurrentUserEmail() !== parent.author ? ['acl_Owner'] : [])] .filter(key => key.startsWith('acl_')) .forEach(key => { // if the default acl mode is private, then don't inherit the acl_guest permission, but set it to private. // const permission: string = key === 'acl_Guest' && Doc.defaultAclPrivate ? AclPrivate : parent[key]; const parAcl = ReverseHierarchyMap.get(StrCast(key === 'acl_Owner' ? (Doc.defaultAclPrivate ? SharingPermissions.None : SharingPermissions.Edit) : parent[key]))?.acl; if (parAcl) { const sharePermission = HierarchyMapping.get(parAcl)?.name; sharePermission && distributeAcls(key === 'acl_Owner' ? `acl_${normalizeEmail(StrCast(parent.author))}` : key, sharePermission, child, undefined, false, layoutOnly); } }); } /** * sets a callback function to be called whenever a value is assigned to the specified field key. * For example, this is used to "publish" documents with titles that start with '@' * @param prop * @param propSetter */ export function SetPropSetterCb(prop: string, propSetter: ((target: any, value: any) => void) | undefined) { _propSetterCB.set(prop, propSetter); } // // target should be either a Doc or ListImpl. receiver should be a Proxy Or List. // export function setter(target: any, inProp: string | symbol | number, value: any, receiver: any): boolean { if (!inProp) { console.log('WARNING: trying to set an empty property. This should be fixed. '); return false; } let prop = inProp; const effectiveAcl = inProp === 'constructor' || typeof inProp === 'symbol' ? AclAdmin : GetPropAcl(target, prop); if (effectiveAcl !== AclEdit && effectiveAcl !== AclAugment && effectiveAcl !== AclAdmin) return true; // if you're trying to change an acl but don't have Admin access / you're trying to change it to something that isn't an acceptable acl, you can't if (typeof prop === 'string' && prop.startsWith('acl_') && (effectiveAcl !== AclAdmin || ![...Object.values(SharingPermissions), undefined].includes(value))) return true; if (typeof prop === 'string' && prop !== '__id' && prop !== '__fieldTuples' && prop.startsWith('_')) { if (!prop.startsWith('__')) prop = prop.substring(1); if (target.__LAYOUT__) { target.__LAYOUT__[prop] = value; return true; } } if (target.__fieldTuples[prop] instanceof ComputedField) { if (target.__fieldTuples[prop].setterscript && value !== undefined && !(value instanceof ComputedField)) { return !!ScriptCast(target.__fieldTuples[prop])?.setterscript?.run({ self: target[SelfProxy], this: target[SelfProxy], value }).success; } } return _setter(target, prop, value, receiver); } function getFieldImpl(target: any, prop: string | number, proxy: any, ignoreProto: boolean = false): any { const field = target.__fieldTuples[prop]; const value = field?.[ToValue]?.(proxy); // converts ComputedFields to values, or unpacks ProxyFields into Proxys if (value) return value.value; if (field === undefined && !ignoreProto && prop !== 'proto') { const proto = getFieldImpl(target, 'proto', proxy, true); // TODO tfs: instead of proxy we could use target[SelfProxy]... I don't which semantics we want or if it really matters if (proto instanceof Doc && GetEffectiveAcl(proto) !== AclPrivate) { return getFieldImpl(proto, prop, proxy, ignoreProto); } } return field; } export function getter(target: any, prop: string | symbol, proxy: any): any { // prettier-ignore switch (prop) { case 'then' : return undefined; case 'constructor': case 'toString': case 'valueOf': case 'serializeInfo': case 'factory': return target[prop]; case DocAcl : return target[DocAcl]; case $mobx: return target.__fieldTuples[prop]; case DocLayout: return target.__LAYOUT__; case Height: case Width: if (GetEffectiveAcl(target) === AclPrivate) return returnZero; // eslint-disable-next-line no-fallthrough default : if (typeof prop === 'symbol') return target[prop]; if (prop.startsWith('isMobX')) return target[prop]; if (prop.startsWith('__')) return target[prop]; if (GetEffectiveAcl(target) === AclPrivate && prop !== 'author') return undefined; } const layoutProp = prop.startsWith('_') ? prop.substring(1) : undefined; if (layoutProp && target.__LAYOUT__) return target.__LAYOUT__[layoutProp]; return getFieldImpl(target, layoutProp ?? prop, proxy); } export function getField(target: any, prop: string | number, ignoreProto: boolean = false): any { return getFieldImpl(target, prop, target[SelfProxy], ignoreProto); } export function deleteProperty(target: any, prop: string | number | symbol) { if (typeof prop === 'symbol') { delete target[prop]; } else { target[SelfProxy][prop] = undefined; } return true; } // this function creates a function that can be used to setup Undo for whenever an ObjectField changes. // the idea is that the Doc field setter can only setup undo at the granularity of an entire field and won't even be called if // just a part of a field (eg. field within an ObjectField) changes. This function returns a function that can be called // whenever an internal ObjectField field changes. It should be passed a 'diff' specification describing the change. Currently, // List's are the only true ObjectFields that can be partially modified (ignoring SchemaHeaderFields which should go away). // The 'diff' specification that a list can send is limited to indicating that something was added, removed, or that the list contents // were replaced. Based on this specification, an Undo event is setup that will save enough information about the ObjectField to be // able to undo and redo the partial change. // export function containedFieldChangedHandler(container: List | Doc, prop: string | number, liveContainedField: ObjectField) { let lastValue: FieldResult = liveContainedField instanceof ObjectField ? ObjectField.MakeCopy(liveContainedField) : liveContainedField; return (diff?: { op: '$addToSet' | '$remFromSet' | '$set'; items: FieldType[] | undefined; length: number | undefined; hint?: any } /* , dummyServerOp?: any */) => { const serializeItems = () => ({ __type: 'list', fields: diff?.items?.map((item: FieldType) => SerializationHelper.Serialize(item)) }); // prettier-ignore const serverOp = diff?.op === '$addToSet' ? { $addToSet: { ['fields.' + prop]: serializeItems() }, length: diff.length } : diff?.op === '$remFromSet' ? { $remFromSet: { ['fields.' + prop]: serializeItems(), hint: diff.hint}, length: diff.length } : { $set: { ['fields.' + prop]: liveContainedField ? SerializationHelper.Serialize(liveContainedField) : undefined } }; if (!(container instanceof Doc) || !container[UpdatingFromServer]) { const prevValue = ObjectField.MakeCopy(lastValue as List); lastValue = ObjectField.MakeCopy(liveContainedField); const newValue = ObjectField.MakeCopy(liveContainedField); if (diff?.op === '$addToSet') { UndoManager.AddEvent( { redo: () => { // console.log('redo $add: ' + prop, diff.items); // bcz: uncomment to log undo (container as any)[prop as any]?.push(...((diff.items || [])?.map((item: any) => item.value ?? item) ?? [])); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }, undo: action(() => { // console.log('undo $add: ' + prop, diff.items); // bcz: uncomment to log undo diff.items?.forEach((item: any) => { const ind = item instanceof SchemaHeaderField // ? (container as any)[prop as any]?.findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading) : (container as any)[prop as any]?.indexOf(item.value ?? item); ind !== undefined && ind !== -1 && (container as any)[prop as any]?.splice(ind, 1); }); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }), prop: 'add ' + (diff.items?.length ?? 0) + ' items to list', }, diff?.items ); } else if (diff?.op === '$remFromSet') { UndoManager.AddEvent( { redo: action(() => { // console.log('redo $rem: ' + prop, diff.items); // bcz: uncomment to log undo diff.items?.forEach((item: any) => { const ind = item instanceof SchemaHeaderField // ? (container as any)[prop as any]?.findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading) : (container as any)[prop as any]?.indexOf(item.value ?? item); ind !== undefined && ind !== -1 && (container as any)[prop as any]?.splice(ind, 1); }); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }), undo: () => { // console.log('undo $rem: ' + prop, diff.items); // bcz: uncomment to log undo diff.items?.forEach((item: any) => { if (item instanceof SchemaHeaderField) { const ind = (prevValue as List).findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading); ind !== -1 && (container as any)[prop as any].findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading) === -1 && (container as any)[prop as any].splice(ind, 0, item); } else { const ind = (prevValue as List).indexOf(item.value ?? item); ind !== -1 && (container as any)[prop as any].indexOf(item.value ?? item) === -1 && (container as any)[prop as any].splice(ind, 0, item); } }); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }, prop: 'remove ' + (diff.items?.length ?? 0) + ' items from list(' + ((container as any)?.title ?? '') + ':' + prop + ')', }, diff?.items ); } else { const setFieldVal = (val: FieldType | undefined) => { container instanceof Doc ? (container[prop as string] = val) : (container[prop as number] = val as FieldType); }; UndoManager.AddEvent( { redo: () => { // console.log('redo list: ' + prop, fieldVal()); // bcz: uncomment to log undo setFieldVal(newValue instanceof ObjectField ? ObjectField.MakeCopy(newValue) : undefined); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }, undo: () => { // console.log('undo list: ' + prop, fieldVal()); // bcz: uncomment to log undo setFieldVal(prevValue instanceof ObjectField ? ObjectField.MakeCopy(prevValue) : undefined); lastValue = ObjectField.MakeCopy((container as any)[prop as any]); }, prop: 'set list field', }, diff?.items ); } } container[FieldChanged]?.(undefined, serverOp); }; }