// // EntityEditFilters.cpp // libraries/entities/src // // Created by David Kelly on 2/7/2017. // Copyright 2017 High Fidelity, Inc. // Copyright 2023 Overte e.V. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // SPDX-License-Identifier: Apache-2.0 // #include "EntityEditFilters.h" #include #include #include #include #include #include QList EntityEditFilters::getZonesByPosition(glm::vec3& position) { QList zones; QList missingZones; _lock.lockForRead(); auto zoneIDs = _filterDataMap.keys(); _lock.unlock(); for (auto id : zoneIDs) { if (!id.isInvalidID()) { // for now, look it up in the tree (soon we need to cache or similar?) EntityItemPointer itemPtr = _tree->findEntityByEntityItemID(id); auto zone = std::dynamic_pointer_cast(itemPtr); if (!zone) { // TODO: maybe remove later? removeFilter(id); } else if (zone->contains(position)) { zones.append(id); } } else { // the null id is the global filter we put in the domain server's // advanced entity server settings zones.append(id); } } return zones; } bool EntityEditFilters::filter(glm::vec3& position, EntityItemProperties& propertiesIn, EntityItemProperties& propertiesOut, bool& wasChanged, EntityTree::FilterType filterType, EntityItemID& itemID, const EntityItemPointer& existingEntity) { // get the ids of all the zones (plus the global entity edit filter) that the position // lies within auto zoneIDs = getZonesByPosition(position); for (auto id : zoneIDs) { if (!itemID.isInvalidID() && id == itemID) { continue; } // get the filter pair, etc... _lock.lockForRead(); FilterData filterData = _filterDataMap.value(id); _lock.unlock(); if (filterData.valid()) { if (filterData.rejectAll) { return false; } // check to see if this filter wants to filter this message type if ((!filterData.wantsToFilterEdit && filterType == EntityTree::FilterType::Edit) || (!filterData.wantsToFilterPhysics && filterType == EntityTree::FilterType::Physics) || (!filterData.wantsToFilterDelete && filterType == EntityTree::FilterType::Delete) || (!filterData.wantsToFilterAdd && filterType == EntityTree::FilterType::Add)) { wasChanged = false; return true; // accept the message } auto oldProperties = propertiesIn.getDesiredProperties(); auto specifiedProperties = propertiesIn.getChangedProperties(); propertiesIn.setDesiredProperties(specifiedProperties); ScriptValue inputValues = propertiesIn.copyToScriptValue(filterData.engine.get(), false, true, true); propertiesIn.setDesiredProperties(oldProperties); auto in = QJsonValue::fromVariant(inputValues.toVariant()); // grab json copy now, because the inputValues might be side effected by the filter. ScriptValueList args; args << inputValues; args << filterData.engine->newValue(filterType); // get the current properties for then entity and include them for the filter call if (existingEntity && filterData.wantsOriginalProperties) { auto currentProperties = existingEntity->getProperties(filterData.includedOriginalProperties); ScriptValue currentValues = currentProperties.copyToScriptValue(filterData.engine.get(), false, true, true); args << currentValues; } // get the zone properties if (filterData.wantsZoneProperties) { auto zoneEntity = _tree->findEntityByEntityItemID(id); if (zoneEntity) { auto zoneProperties = zoneEntity->getProperties(filterData.includedZoneProperties); ScriptValue zoneValues = zoneProperties.copyToScriptValue(filterData.engine.get(), false, true, true); if (filterData.wantsZoneBoundingBox) { bool success = true; AABox aaBox = zoneEntity->getAABox(success); if (success) { ScriptValue boundingBox = filterData.engine->newObject(); ScriptValue bottomRightNear = vec3ToScriptValue(filterData.engine.get(), aaBox.getCorner()); ScriptValue topFarLeft = vec3ToScriptValue(filterData.engine.get(), aaBox.calcTopFarLeft()); ScriptValue center = vec3ToScriptValue(filterData.engine.get(), aaBox.calcCenter()); ScriptValue boundingBoxDimensions = vec3ToScriptValue(filterData.engine.get(), aaBox.getDimensions()); boundingBox.setProperty("brn", bottomRightNear); boundingBox.setProperty("tfl", topFarLeft); boundingBox.setProperty("center", center); boundingBox.setProperty("dimensions", boundingBoxDimensions); zoneValues.setProperty("boundingBox", boundingBox); } } // If this is an add or delete, or original properties weren't requested // there won't be original properties in the args, but zone properties need // to be the fourth parameter, so we need to pad the args accordingly int EXPECTED_ARGS = 3; if (args.length() < EXPECTED_ARGS) { args << ScriptValue(); } assert(args.length() == EXPECTED_ARGS); // we MUST have 3 args by now! args << zoneValues; } } ScriptValue result = filterData.filterFn.call(_nullObjectForFilter, args); if (filterData.uncaughtExceptions()) { return false; } if (result.isObject()) { // make propertiesIn reflect the changes, for next filter... propertiesIn.copyFromScriptValue(result, false); // and update propertiesOut too. TODO: this could be more efficient... propertiesOut.copyFromScriptValue(result, false); // Javascript objects are == only if they are the same object. To compare arbitrary values, we need to use JSON. auto out = QJsonValue::fromVariant(result.toVariant()); wasChanged |= (in != out); } else if (result.isBool()) { // if the filter returned false, then it's authoritative if (!result.toBool()) { return false; } // otherwise, assume it wants to pass all properties propertiesOut = propertiesIn; wasChanged = false; } else { return false; } } } // if we made it here, return true; } void EntityEditFilters::removeFilter(EntityItemID entityID) { QWriteLocker writeLock(&_lock); _filterDataMap.remove(entityID); } void EntityEditFilters::addFilter(EntityItemID entityID, QString filterURL) { QUrl scriptURL(filterURL); // setting it to an empty string is same as removing if (filterURL.size() == 0) { removeFilter(entityID); return; } // The following should be abstracted out for use in Agent.cpp (and maybe later AvatarMixer.cpp) if (scriptURL.scheme().isEmpty() || (scriptURL.scheme() == HIFI_URL_SCHEME_FILE)) { qWarning() << "Cannot load script from local filesystem, because assignment may be on a different computer."; scriptRequestFinished(entityID); return; } // first remove any existing info for this entity removeFilter(entityID); // reject all edits until we load the script FilterData filterData; filterData.rejectAll = true; _lock.lockForWrite(); _filterDataMap.insert(entityID, filterData); _lock.unlock(); auto scriptRequest = DependencyManager::get()->createResourceRequest( this, scriptURL, true, -1, "EntityEditFilters::addFilter"); if (!scriptRequest) { qWarning() << "Could not create ResourceRequest for Entity Edit filter."; scriptRequestFinished(entityID); return; } // Agent.cpp sets up a timeout here, but that is unnecessary, as ResourceRequest has its own. connect(scriptRequest, &ResourceRequest::finished, this, [this, entityID]{ EntityEditFilters::scriptRequestFinished(entityID);} ); // FIXME: handle atp rquests setup here. See Agent::requestScript() scriptRequest->send(); qDebug() << "script request sent for entity " << entityID; } // Copied from ScriptEngine.cpp. We should make this a class method for reuse. // Note: I've deliberately stopped short of using ScriptEngine instead of ScriptEngine, as that is out of project scope at this point. static bool hasCorrectSyntax(const ScriptProgramPointer& program) { const auto syntaxCheck = program->checkSyntax(); if (syntaxCheck->state() != ScriptSyntaxCheckResult::Valid) { const auto error = syntaxCheck->errorMessage(); const auto line = QString::number(syntaxCheck->errorLineNumber()); const auto column = QString::number(syntaxCheck->errorColumnNumber()); const auto message = QString("[SyntaxError] %1 in %2:%3(%4)").arg(error, program->fileName(), line, column); qCritical() << qPrintable(message); return false; } return true; } static bool hadUncaughtExceptions(ScriptEngine& engine, const QString& fileName) { if (engine.hasUncaughtException()) { qCritical() << engine.uncaughtException(); engine.clearExceptions(); return true; } return false; } void EntityEditFilters::scriptRequestFinished(EntityItemID entityID) { qDebug() << "script request completed for entity " << entityID; auto scriptRequest = qobject_cast(sender()); if (scriptRequest && scriptRequest->getResult() == ResourceRequest::Success) { const QString urlString = scriptRequest->getUrl().toString(); auto scriptContents = scriptRequest->getData(); qInfo() << "Downloaded script:" << scriptContents; // create a ScriptEngine for this script ScriptManagerPointer manager = newScriptManager(ScriptManager::ENTITY_SERVER_SCRIPT, "", urlString); ScriptEnginePointer engine = manager->engine(); ScriptProgramPointer program = engine->newProgram(scriptContents, urlString); if (hasCorrectSyntax(program)) { engine->setObjectName("filter:" + entityID.toString()); engine->setProperty("type", "edit_filter"); engine->setProperty("fileName", urlString); engine->setProperty("entityID", entityID); engine->globalObject().setProperty("Script", engine->newQObject(manager.get())); DependencyManager::get()->runScriptInitializers(engine.get()); engine->evaluate(scriptContents, urlString); if (!hadUncaughtExceptions(*engine, urlString)) { // put the engine in the engine map (so we don't leak them, etc...) FilterData filterData; filterData.engine = engine; filterData.rejectAll = false; // define the uncaughtException function ScriptEngine& engineRef = *engine; filterData.uncaughtExceptions = [&engineRef, urlString]() { return hadUncaughtExceptions(engineRef, urlString); }; // now get the filter function auto global = engine->globalObject(); auto entitiesObject = engine->newObject(); entitiesObject.setProperty("ADD_FILTER_TYPE", EntityTree::FilterType::Add); entitiesObject.setProperty("EDIT_FILTER_TYPE", EntityTree::FilterType::Edit); entitiesObject.setProperty("PHYSICS_FILTER_TYPE", EntityTree::FilterType::Physics); entitiesObject.setProperty("DELETE_FILTER_TYPE", EntityTree::FilterType::Delete); global.setProperty("Entities", entitiesObject); filterData.filterFn = global.property("filter"); if (!filterData.filterFn.isFunction()) { qDebug() << "Filter function specified but not found. Will reject all edits for those without lock rights."; engine.reset(); filterData.rejectAll=true; } // if the wantsToFilterEdit is a boolean evaluate as a boolean, otherwise assume true ScriptValue wantsToFilterAddValue = filterData.filterFn.property("wantsToFilterAdd"); filterData.wantsToFilterAdd = wantsToFilterAddValue.isBool() ? wantsToFilterAddValue.toBool() : true; // if the wantsToFilterEdit is a boolean evaluate as a boolean, otherwise assume true ScriptValue wantsToFilterEditValue = filterData.filterFn.property("wantsToFilterEdit"); filterData.wantsToFilterEdit = wantsToFilterEditValue.isBool() ? wantsToFilterEditValue.toBool() : true; // if the wantsToFilterPhysics is a boolean evaluate as a boolean, otherwise assume true ScriptValue wantsToFilterPhysicsValue = filterData.filterFn.property("wantsToFilterPhysics"); filterData.wantsToFilterPhysics = wantsToFilterPhysicsValue.isBool() ? wantsToFilterPhysicsValue.toBool() : true; // if the wantsToFilterDelete is a boolean evaluate as a boolean, otherwise assume false ScriptValue wantsToFilterDeleteValue = filterData.filterFn.property("wantsToFilterDelete"); filterData.wantsToFilterDelete = wantsToFilterDeleteValue.isBool() ? wantsToFilterDeleteValue.toBool() : false; // check to see if the filterFn has properties asking for Original props ScriptValue wantsOriginalPropertiesValue = filterData.filterFn.property("wantsOriginalProperties"); // if the wantsOriginalProperties is a boolean, or a string, or list of strings, then evaluate as follows: // - boolean - true - include all original properties // false - no properties at all // - string - empty - no properties at all // any valid property - include just that property in the Original properties // - list of strings - include only those properties in the Original properties if (wantsOriginalPropertiesValue.isBool()) { filterData.wantsOriginalProperties = wantsOriginalPropertiesValue.toBool(); } else if (wantsOriginalPropertiesValue.isString()) { auto stringValue = wantsOriginalPropertiesValue.toString(); filterData.wantsOriginalProperties = !stringValue.isEmpty(); if (filterData.wantsOriginalProperties) { EntityPropertyFlagsFromScriptValue(wantsOriginalPropertiesValue, filterData.includedOriginalProperties); } } else if (wantsOriginalPropertiesValue.isArray()) { EntityPropertyFlagsFromScriptValue(wantsOriginalPropertiesValue, filterData.includedOriginalProperties); filterData.wantsOriginalProperties = !filterData.includedOriginalProperties.isEmpty(); } // check to see if the filterFn has properties asking for Zone props ScriptValue wantsZonePropertiesValue = filterData.filterFn.property("wantsZoneProperties"); // if the wantsZoneProperties is a boolean, or a string, or list of strings, then evaluate as follows: // - boolean - true - include all Zone properties // false - no properties at all // - string - empty - no properties at all // any valid property - include just that property in the Zone properties // - list of strings - include only those properties in the Zone properties if (wantsZonePropertiesValue.isBool()) { filterData.wantsZoneProperties = wantsZonePropertiesValue.toBool(); filterData.wantsZoneBoundingBox = filterData.wantsZoneProperties; // include this too } else if (wantsZonePropertiesValue.isString()) { auto stringValue = wantsZonePropertiesValue.toString(); filterData.wantsZoneProperties = !stringValue.isEmpty(); if (filterData.wantsZoneProperties) { if (stringValue == "boundingBox") { filterData.wantsZoneBoundingBox = true; } else { EntityPropertyFlagsFromScriptValue(wantsZonePropertiesValue, filterData.includedZoneProperties); } } } else if (wantsZonePropertiesValue.isArray()) { auto length = wantsZonePropertiesValue.property("length").toInteger(); for (int i = 0; i < length; i++) { auto stringValue = wantsZonePropertiesValue.property(i).toString(); if (!stringValue.isEmpty()) { filterData.wantsZoneProperties = true; // boundingBox is a special case since it's not a true EntityPropertyFlag, so we // need to detect it here. if (stringValue == "boundingBox") { filterData.wantsZoneBoundingBox = true; break; // we can break here, since there are no other special cases } } } if (filterData.wantsZoneProperties) { EntityPropertyFlagsFromScriptValue(wantsZonePropertiesValue, filterData.includedZoneProperties); } } _lock.lockForWrite(); _filterDataMap.insert(entityID, filterData); _lock.unlock(); qDebug() << "script request filter processed for entity id " << entityID; emit filterAdded(entityID, true); return; } } } else if (scriptRequest) { const QString urlString = scriptRequest->getUrl().toString(); qCritical() << "Failed to download script"; // See HTTPResourceRequest::onRequestFinished for interpretation of codes. For example, a 404 is code 6 and 403 is 3. A timeout is 2. Go figure. qCritical() << "ResourceRequest error was" << scriptRequest->getResult(); } else { qCritical() << "Failed to create script request."; } emit filterAdded(entityID, false); }