//// AssetScriptingInterface.cpp// libraries/script-engine/src//// Created by Stephen Birarda on 2016-03-08.// Copyright 2016 High Fidelity, Inc.//// Distributed under the Apache License, Version 2.0.// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html// #include "AssetScriptingInterface.h" #include <QMimeDatabase>#include <QThread>#include <QtScript/QScriptEngine> #include <AssetRequest.h>#include <AssetUpload.h>#include <AssetUtils.h>#include <BaseScriptEngine.h>#include <MappingRequest.h>#include <NodeList.h> #include <RegisteredMetaTypes.h>#include "ScriptEngine.h"#include "ScriptEngineLogging.h" #include <shared/QtHelpers.h>#include <Gzip.h> using Promise = MiniPromise::Promise; AssetScriptingInterface::AssetScriptingInterface(QObject* parent) : BaseAssetScriptingInterface(parent) { qCDebug(scriptengine) << "AssetScriptingInterface::AssetScriptingInterface" << parent; MiniPromise::registerMetaTypes(parent);} #define JS_VERIFY(cond, error) { if (!this->jsVerify(cond, error)) { return; } } void AssetScriptingInterface::uploadData(QString data, QScriptValue callback) { auto handler = jsBindCallback(thisObject(), callback); QByteArray dataByteArray = data.toUtf8(); auto upload = DependencyManager::get<AssetClient>()->createUpload(dataByteArray); Promise deferred = makePromise(__FUNCTION__); deferred->ready([=](QString error, QVariantMap result) { auto url = result.value("url").toString(); auto hash = result.value("hash").toString(); jsCallback(handler, url, hash); }); connect(upload, &AssetUpload::finished, upload, [this, deferred](AssetUpload* upload, const QString& hash) { // we are now on the "Resource Manager" thread (and "hash" being a *reference* makes it unsafe to use directly) Q_ASSERT(QThread::currentThread() == upload->thread()); deferred->resolve({ { "url", "atp:" + hash }, { "hash", hash }, }); upload->deleteLater(); }); upload->start();} void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValue callback) { auto handler = jsBindCallback(thisObject(), callback); auto setMappingRequest = assetClient()->createSetMappingRequest(path, hash); Promise deferred = makePromise(__FUNCTION__); deferred->ready([=](QString error, QVariantMap result) { jsCallback(handler, error, result); }); connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [this, deferred](SetMappingRequest* request) { Q_ASSERT(QThread::currentThread() == request->thread()); // we are now on the "Resource Manager" thread QString error = request->getErrorString(); // forward a thread-safe values back to our thread deferred->handle(error, { { "error", request->getError() } }); request->deleteLater(); }); setMappingRequest->start();} void AssetScriptingInterface::downloadData(QString urlString, QScriptValue callback) { // FIXME: historically this API method failed silently when given a non-atp prefixed // urlString (or if the AssetRequest failed). // .. is that by design or could we update without breaking things to provide better feedback to scripts? if (!urlString.startsWith(ATP_SCHEME)) { // ... for now at least log a message so user can check logs qCDebug(scriptengine) << "AssetScriptingInterface::downloadData url must be of form atp:<hash-value>"; return; } QString hash = AssetUtils::extractAssetHash(urlString); auto handler = jsBindCallback(thisObject(), callback); auto assetClient = DependencyManager::get<AssetClient>(); auto assetRequest = assetClient->createRequest(hash); Promise deferred = makePromise(__FUNCTION__); deferred->ready([=](QString error, QVariantMap result) { // FIXME: to remain backwards-compatible the signature here is "callback(data, n/a)" jsCallback(handler, result.value("data").toString(), { { "errorMessage", error } }); }); connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) { Q_ASSERT(QThread::currentThread() == request->thread()); // we are now on the "Resource Manager" thread Q_ASSERT(request->getState() == AssetRequest::Finished); if (request->getError() == AssetRequest::Error::NoError) { QString data = QString::fromUtf8(request->getData()); // forward a thread-safe values back to our thread deferred->resolve({ { "data", data } }); } else { // FIXME: propagate error to scripts? (requires changing signature or inverting param order above..) //deferred->resolve(request->getErrorString(), { { "error", requet->getError() } }); qCDebug(scriptengine) << "AssetScriptingInterface::downloadData ERROR: " << request->getErrorString(); } request->deleteLater(); }); assetRequest->start();} void AssetScriptingInterface::setBakingEnabled(QString path, bool enabled, QScriptValue callback) { auto setBakingEnabledRequest = DependencyManager::get<AssetClient>()->createSetBakingEnabledRequest({ path }, enabled); Promise deferred = jsPromiseReady(makePromise(__FUNCTION__), thisObject(), callback); connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, setBakingEnabledRequest, [this, deferred](SetBakingEnabledRequest* request) { Q_ASSERT(QThread::currentThread() == request->thread()); // we are now on the "Resource Manager" thread QString error = request->getErrorString(); // forward thread-safe values back to our thread deferred->handle(error, {}); request->deleteLater(); }); setBakingEnabledRequest->start();} #if (PR_BUILD || DEV_BUILD)void AssetScriptingInterface::sendFakedHandshake() { auto nodeList = DependencyManager::get<NodeList>(); SharedNodePointer assetServer = nodeList->soloNodeOfType(NodeType::AssetServer); nodeList->sendFakedHandshakeRequestToNode(assetServer);} #endif void AssetScriptingInterface::getMapping(QString asset, QScriptValue callback) { auto path = AssetUtils::getATPUrl(asset).path(); auto handler = jsBindCallback(thisObject(), callback); JS_VERIFY(AssetUtils::isValidFilePath(path), "invalid ATP file path: " + asset + "(path:"+path+")"); JS_VERIFY(callback.isFunction(), "expected second parameter to be a callback function"); Promise promise = getAssetInfo(path); promise->ready([=](QString error, QVariantMap result) { jsCallback(handler, error, result.value("hash").toString()); });} bool AssetScriptingInterface::jsVerify(bool condition, const QString& error) { if (condition) { return true; } if (context()) { context()->throwError(error); } else { qCDebug(scriptengine) << "WARNING -- jsVerify failed outside of a valid JS context: " + error; } return false;} QScriptValue AssetScriptingInterface::jsBindCallback(QScriptValue scope, QScriptValue callback) { QScriptValue handler = ::makeScopedHandlerObject(scope, callback); QScriptValue value = handler.property("callback"); if (!jsVerify(handler.isObject() && value.isFunction(), QString("jsBindCallback -- .callback is not a function (%1)").arg(value.toVariant().typeName()))) { return QScriptValue(); } return handler;} Promise AssetScriptingInterface::jsPromiseReady(Promise promise, QScriptValue scope, QScriptValue callback) { auto handler = jsBindCallback(scope, callback); if (!jsVerify(handler.isValid(), "jsPromiseReady -- invalid callback handler")) { return nullptr; } return promise->ready([this, handler](QString error, QVariantMap result) { jsCallback(handler, error, result); });} void AssetScriptingInterface::jsCallback(const QScriptValue& handler, const QScriptValue& error, const QScriptValue& result) { Q_ASSERT(thread() == QThread::currentThread()); auto errorValue = !error.toBool() ? QScriptValue::NullValue : error; JS_VERIFY(handler.isObject() && handler.property("callback").isFunction(), QString("jsCallback -- .callback is not a function (%1)") .arg(handler.property("callback").toVariant().typeName())); ::callScopedHandlerObject(handler, errorValue, result);} void AssetScriptingInterface::jsCallback(const QScriptValue& handler, const QScriptValue& error, const QVariantMap& result) { Q_ASSERT(thread() == QThread::currentThread()); Q_ASSERT(handler.engine()); auto engine = handler.engine(); jsCallback(handler, error, engine->toScriptValue(result));} void AssetScriptingInterface::deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { jsVerify(false, "TODO: deleteAsset API");} void AssetScriptingInterface::getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { JS_VERIFY(options.isObject() || options.isString(), "expected request options Object or URL as first parameter"); auto decompress = options.property("decompress").toBool() || options.property("compressed").toBool(); auto responseType = options.property("responseType").toString().toLower(); auto url = options.property("url").toString(); if (options.isString()) { url = options.toString(); } if (responseType.isEmpty() || responseType == "string") { responseType = "text"; } auto asset = AssetUtils::getATPUrl(url).path(); JS_VERIFY(AssetUtils::isValidHash(asset) || AssetUtils::isValidFilePath(asset), QString("Invalid ATP url '%1'").arg(url)); JS_VERIFY(RESPONSE_TYPES.contains(responseType), QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | "))); Promise fetched = jsPromiseReady(makePromise("fetched"), scope, callback); Promise mapped = makePromise("mapped"); mapped->ready([=](QString error, QVariantMap result) { QString hash = result.value("hash").toString(); QString url = result.value("url").toString(); if (!error.isEmpty() || !AssetUtils::isValidHash(hash)) { fetched->reject(error.isEmpty() ? "internal hash error: " + hash : error, result); } else { Promise promise = loadAsset(hash, decompress, responseType); promise->mixin(result); promise->ready([=](QString error, QVariantMap loadResult) { loadResult["url"] = url; // maintain mapped .url in results (vs. atp:hash returned by loadAsset) fetched->handle(error, loadResult); }); } }); if (AssetUtils::isValidHash(asset)) { mapped->resolve({ { "hash", asset }, { "url", url }, }); } else { getAssetInfo(asset)->ready(mapped); }} void AssetScriptingInterface::resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { const QString& URL{ "url" }; auto url = (options.isString() ? options : options.property(URL)).toString(); auto asset = AssetUtils::getATPUrl(url).path(); JS_VERIFY(AssetUtils::isValidFilePath(asset) || AssetUtils::isValidHash(asset), "expected options to be an asset URL or request options containing .url property"); jsPromiseReady(getAssetInfo(asset), scope, callback);} void AssetScriptingInterface::decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback) { auto data = options.property("data"); QByteArray dataByteArray = qscriptvalue_cast<QByteArray>(data); auto responseType = options.property("responseType").toString().toLower(); if (responseType.isEmpty()) { responseType = "text"; } Promise completed = jsPromiseReady(makePromise(__FUNCTION__), scope, callback); Promise decompressed = decompressBytes(dataByteArray); if (responseType == "arraybuffer") { decompressed->ready(completed); } else { decompressed->ready([=](QString error, QVariantMap result) { Promise converted = convertBytes(result.value("data").toByteArray(), responseType); converted->mixin(result); converted->ready(completed); }); }} namespace { const int32_t DEFAULT_GZIP_COMPRESSION_LEVEL = -1; const int32_t MAX_GZIP_COMPRESSION_LEVEL = 9;}void AssetScriptingInterface::compressData(QScriptValue options, QScriptValue scope, QScriptValue callback) { auto data = options.property("data").isValid() ? options.property("data") : options; QByteArray dataByteArray = data.isString() ? data.toString().toUtf8() : qscriptvalue_cast<QByteArray>(data); int level = options.property("level").isNumber() ? options.property("level").toInt32() : DEFAULT_GZIP_COMPRESSION_LEVEL; JS_VERIFY(level >= DEFAULT_GZIP_COMPRESSION_LEVEL || level <= MAX_GZIP_COMPRESSION_LEVEL, QString("invalid .level %1").arg(level)); jsPromiseReady(compressBytes(dataByteArray, level), scope, callback);} void AssetScriptingInterface::putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) { auto compress = options.property("compress").toBool() || options.property("compressed").toBool(); auto data = options.isObject() ? options.property("data") : options; auto rawPath = options.property("path").toString(); auto path = AssetUtils::getATPUrl(rawPath).path(); QByteArray dataByteArray = data.isString() ? data.toString().toUtf8() : qscriptvalue_cast<QByteArray>(data); JS_VERIFY(path.isEmpty() || AssetUtils::isValidFilePath(path), QString("expected valid ATP file path '%1' ('%2')").arg(rawPath).arg(path)); JS_VERIFY(dataByteArray.size() > 0, QString("expected non-zero .data (got %1 / #%2 bytes)").arg(data.toVariant().typeName()).arg(dataByteArray.size())); // [compressed] => uploaded to server => [mapped to path] Promise prepared = makePromise("putAsset::prepared"); Promise uploaded = makePromise("putAsset::uploaded"); Promise completed = makePromise("putAsset::completed"); jsPromiseReady(completed, scope, callback); if (compress) { Promise compress = compressBytes(dataByteArray, DEFAULT_GZIP_COMPRESSION_LEVEL); compress->ready(prepared); } else { prepared->resolve({{ "data", dataByteArray }}); } prepared->fail(completed); prepared->then([=](QVariantMap result) { Promise upload = uploadBytes(result.value("data").toByteArray()); upload->mixin(result); upload->ready(uploaded); }); uploaded->fail(completed); if (path.isEmpty()) { uploaded->then(completed); } else { uploaded->then([=](QVariantMap result) { QString hash = result.value("hash").toString(); if (!AssetUtils::isValidHash(hash)) { completed->reject("path mapping requested, but did not receive valid hash", result); } else { Promise link = symlinkAsset(hash, path); link->mixin(result); link->ready(completed); } }); }} void AssetScriptingInterface::queryCacheMeta(QScriptValue options, QScriptValue scope, QScriptValue callback) { QString url = options.isString() ? options.toString() : options.property("url").toString(); JS_VERIFY(QUrl(url).isValid(), QString("Invalid URL '%1'").arg(url)); jsPromiseReady(Parent::queryCacheMeta(url), scope, callback);} void AssetScriptingInterface::loadFromCache(QScriptValue options, QScriptValue scope, QScriptValue callback) { QString url, responseType; bool decompress = false; if (options.isString()) { url = options.toString(); responseType = "text"; } else { url = options.property("url").toString(); responseType = options.property("responseType").isValid() ? options.property("responseType").toString() : "text"; decompress = options.property("decompress").toBool() || options.property("compressed").toBool(); } JS_VERIFY(QUrl(url).isValid(), QString("Invalid URL '%1'").arg(url)); JS_VERIFY(RESPONSE_TYPES.contains(responseType), QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | "))); jsPromiseReady(Parent::loadFromCache(url, decompress, responseType), scope, callback);} bool AssetScriptingInterface::canWriteCacheValue(const QUrl& url) { auto scriptEngine = qobject_cast<ScriptEngine*>(engine()); if (!scriptEngine) { qCDebug(scriptengine) << __FUNCTION__ << "invalid script engine" << url; return false; } // allow cache writes only from Client, EntityServer and Agent scripts bool isAllowedContext = ( scriptEngine->isClientScript() || scriptEngine->isAgentScript() || scriptEngine->isEntityServerScript() ); if (!isAllowedContext) { qCDebug(scriptengine) << __FUNCTION__ << "invalid context" << scriptEngine->getContext() << url; return false; } return true;} void AssetScriptingInterface::saveToCache(QScriptValue options, QScriptValue scope, QScriptValue callback) { JS_VERIFY(options.isObject(), QString("expected options object as first parameter not: %1").arg(options.toVariant().typeName())); QString url = options.property("url").toString(); QByteArray data = qscriptvalue_cast<QByteArray>(options.property("data")); QVariantMap headers = qscriptvalue_cast<QVariantMap>(options.property("headers")); saveToCache(url, data, headers, scope, callback);} void AssetScriptingInterface::saveToCache(const QUrl& rawURL, const QByteArray& data, const QVariantMap& metadata, QScriptValue scope, QScriptValue callback) { QUrl url = rawURL; if (url.path().isEmpty() && !data.isEmpty()) { // generate a valid ATP URL from the data -- appending any existing fragment or querystring values auto atpURL = AssetUtils::getATPUrl(hashDataHex(data)); atpURL.setQuery(url.query()); atpURL.setFragment(url.fragment()); qCDebug(scriptengine) << "autogenerated ATP URL" << url << "=>" << atpURL; url = atpURL; } auto hash = AssetUtils::extractAssetHash(url.toDisplayString()); JS_VERIFY(url.isValid(), QString("Invalid URL '%1'").arg(url.toString())); JS_VERIFY(canWriteCacheValue(url), "Invalid cache write URL: " + url.toString()); JS_VERIFY(url.scheme() == "atp" || url.scheme() == "cache", "only 'atp' and 'cache' URL schemes supported"); JS_VERIFY(hash.isEmpty() || hash == hashDataHex(data), QString("invalid checksum hash for atp:HASH style URL (%1 != %2)").arg(hash, hashDataHex(data))); qCDebug(scriptengine) << "saveToCache" << url.toDisplayString() << data << hash << metadata; jsPromiseReady(Parent::saveToCache(url, data, metadata), scope, callback);}
//
// AssetScriptingInterface.cpp
// libraries/script-engine/src
//
// Created by Stephen Birarda on 2016-03-08.
// Copyright 2016 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
#include "AssetScriptingInterface.h"
#include <QMimeDatabase>
#include <QThread>
#include <QtScript/QScriptEngine>
#include <AssetRequest.h>
#include <AssetUpload.h>
#include <AssetUtils.h>
#include <BaseScriptEngine.h>
#include <MappingRequest.h>
#include <NodeList.h>
#include <RegisteredMetaTypes.h>
#include "ScriptEngine.h"
#include "ScriptEngineLogging.h"
#include <shared/QtHelpers.h>
#include <Gzip.h>
using Promise = MiniPromise::Promise;
AssetScriptingInterface::AssetScriptingInterface(QObject* parent) : BaseAssetScriptingInterface(parent) {
qCDebug(scriptengine) << "AssetScriptingInterface::AssetScriptingInterface" << parent;
MiniPromise::registerMetaTypes(parent);
}
#define JS_VERIFY(cond, error) { if (!this->jsVerify(cond, error)) { return; } }
void AssetScriptingInterface::uploadData(QString data, QScriptValue callback) {
auto handler = jsBindCallback(thisObject(), callback);
QByteArray dataByteArray = data.toUtf8();
auto upload = DependencyManager::get<AssetClient>()->createUpload(dataByteArray);
Promise deferred = makePromise(__FUNCTION__);
deferred->ready([=](QString error, QVariantMap result) {
auto url = result.value("url").toString();
auto hash = result.value("hash").toString();
jsCallback(handler, url, hash);
});
connect(upload, &AssetUpload::finished, upload, [this, deferred](AssetUpload* upload, const QString& hash) {
// we are now on the "Resource Manager" thread (and "hash" being a *reference* makes it unsafe to use directly)
Q_ASSERT(QThread::currentThread() == upload->thread());
deferred->resolve({
{ "url", "atp:" + hash },
{ "hash", hash },
});
upload->deleteLater();
});
upload->start();
}
void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValue callback) {
auto handler = jsBindCallback(thisObject(), callback);
auto setMappingRequest = assetClient()->createSetMappingRequest(path, hash);
Promise deferred = makePromise(__FUNCTION__);
deferred->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [this, deferred](SetMappingRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
QString error = request->getErrorString();
// forward a thread-safe values back to our thread
deferred->handle(error, { { "error", request->getError() } });
request->deleteLater();
});
setMappingRequest->start();
}
void AssetScriptingInterface::downloadData(QString urlString, QScriptValue callback) {
// FIXME: historically this API method failed silently when given a non-atp prefixed
// urlString (or if the AssetRequest failed).
// .. is that by design or could we update without breaking things to provide better feedback to scripts?
if (!urlString.startsWith(ATP_SCHEME)) {
// ... for now at least log a message so user can check logs
qCDebug(scriptengine) << "AssetScriptingInterface::downloadData url must be of form atp:<hash-value>";
return;
}
QString hash = AssetUtils::extractAssetHash(urlString);
auto handler = jsBindCallback(thisObject(), callback);
auto assetClient = DependencyManager::get<AssetClient>();
auto assetRequest = assetClient->createRequest(hash);
Promise deferred = makePromise(__FUNCTION__);
deferred->ready([=](QString error, QVariantMap result) {
// FIXME: to remain backwards-compatible the signature here is "callback(data, n/a)"
jsCallback(handler, result.value("data").toString(), { { "errorMessage", error } });
});
connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
Q_ASSERT(request->getState() == AssetRequest::Finished);
if (request->getError() == AssetRequest::Error::NoError) {
QString data = QString::fromUtf8(request->getData());
// forward a thread-safe values back to our thread
deferred->resolve({ { "data", data } });
} else {
// FIXME: propagate error to scripts? (requires changing signature or inverting param order above..)
//deferred->resolve(request->getErrorString(), { { "error", requet->getError() } });
qCDebug(scriptengine) << "AssetScriptingInterface::downloadData ERROR: " << request->getErrorString();
}
request->deleteLater();
});
assetRequest->start();
}
void AssetScriptingInterface::setBakingEnabled(QString path, bool enabled, QScriptValue callback) {
auto setBakingEnabledRequest = DependencyManager::get<AssetClient>()->createSetBakingEnabledRequest({ path }, enabled);
Promise deferred = jsPromiseReady(makePromise(__FUNCTION__), thisObject(), callback);
connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, setBakingEnabledRequest, [this, deferred](SetBakingEnabledRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
QString error = request->getErrorString();
// forward thread-safe values back to our thread
deferred->handle(error, {});
request->deleteLater();
});
setBakingEnabledRequest->start();
}
#if (PR_BUILD || DEV_BUILD)
void AssetScriptingInterface::sendFakedHandshake() {
auto nodeList = DependencyManager::get<NodeList>();
SharedNodePointer assetServer = nodeList->soloNodeOfType(NodeType::AssetServer);
nodeList->sendFakedHandshakeRequestToNode(assetServer);
}
#endif
void AssetScriptingInterface::getMapping(QString asset, QScriptValue callback) {
auto path = AssetUtils::getATPUrl(asset).path();
auto handler = jsBindCallback(thisObject(), callback);
JS_VERIFY(AssetUtils::isValidFilePath(path), "invalid ATP file path: " + asset + "(path:"+path+")");
JS_VERIFY(callback.isFunction(), "expected second parameter to be a callback function");
Promise promise = getAssetInfo(path);
promise->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result.value("hash").toString());
});
}
bool AssetScriptingInterface::jsVerify(bool condition, const QString& error) {
if (condition) {
return true;
}
if (context()) {
context()->throwError(error);
} else {
qCDebug(scriptengine) << "WARNING -- jsVerify failed outside of a valid JS context: " + error;
}
return false;
}
QScriptValue AssetScriptingInterface::jsBindCallback(QScriptValue scope, QScriptValue callback) {
QScriptValue handler = ::makeScopedHandlerObject(scope, callback);
QScriptValue value = handler.property("callback");
if (!jsVerify(handler.isObject() && value.isFunction(),
QString("jsBindCallback -- .callback is not a function (%1)").arg(value.toVariant().typeName()))) {
return QScriptValue();
}
return handler;
}
Promise AssetScriptingInterface::jsPromiseReady(Promise promise, QScriptValue scope, QScriptValue callback) {
auto handler = jsBindCallback(scope, callback);
if (!jsVerify(handler.isValid(), "jsPromiseReady -- invalid callback handler")) {
return nullptr;
}
return promise->ready([this, handler](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
}
void AssetScriptingInterface::jsCallback(const QScriptValue& handler,
const QScriptValue& error, const QScriptValue& result) {
Q_ASSERT(thread() == QThread::currentThread());
auto errorValue = !error.toBool() ? QScriptValue::NullValue : error;
JS_VERIFY(handler.isObject() && handler.property("callback").isFunction(),
QString("jsCallback -- .callback is not a function (%1)")
.arg(handler.property("callback").toVariant().typeName()));
::callScopedHandlerObject(handler, errorValue, result);
}
void AssetScriptingInterface::jsCallback(const QScriptValue& handler,
const QScriptValue& error, const QVariantMap& result) {
Q_ASSERT(thread() == QThread::currentThread());
Q_ASSERT(handler.engine());
auto engine = handler.engine();
jsCallback(handler, error, engine->toScriptValue(result));
}
void AssetScriptingInterface::deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
jsVerify(false, "TODO: deleteAsset API");
}
void AssetScriptingInterface::getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
JS_VERIFY(options.isObject() || options.isString(), "expected request options Object or URL as first parameter");
auto decompress = options.property("decompress").toBool() || options.property("compressed").toBool();
auto responseType = options.property("responseType").toString().toLower();
auto url = options.property("url").toString();
if (options.isString()) {
url = options.toString();
}
if (responseType.isEmpty() || responseType == "string") {
responseType = "text";
}
auto asset = AssetUtils::getATPUrl(url).path();
JS_VERIFY(AssetUtils::isValidHash(asset) || AssetUtils::isValidFilePath(asset),
QString("Invalid ATP url '%1'").arg(url));
JS_VERIFY(RESPONSE_TYPES.contains(responseType),
QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | ")));
Promise fetched = jsPromiseReady(makePromise("fetched"), scope, callback);
Promise mapped = makePromise("mapped");
mapped->ready([=](QString error, QVariantMap result) {
QString hash = result.value("hash").toString();
QString url = result.value("url").toString();
if (!error.isEmpty() || !AssetUtils::isValidHash(hash)) {
fetched->reject(error.isEmpty() ? "internal hash error: " + hash : error, result);
} else {
Promise promise = loadAsset(hash, decompress, responseType);
promise->mixin(result);
promise->ready([=](QString error, QVariantMap loadResult) {
loadResult["url"] = url; // maintain mapped .url in results (vs. atp:hash returned by loadAsset)
fetched->handle(error, loadResult);
});
}
});
if (AssetUtils::isValidHash(asset)) {
mapped->resolve({
{ "hash", asset },
{ "url", url },
});
} else {
getAssetInfo(asset)->ready(mapped);
}
}
void AssetScriptingInterface::resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
const QString& URL{ "url" };
auto url = (options.isString() ? options : options.property(URL)).toString();
auto asset = AssetUtils::getATPUrl(url).path();
JS_VERIFY(AssetUtils::isValidFilePath(asset) || AssetUtils::isValidHash(asset),
"expected options to be an asset URL or request options containing .url property");
jsPromiseReady(getAssetInfo(asset), scope, callback);
}
void AssetScriptingInterface::decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto data = options.property("data");
QByteArray dataByteArray = qscriptvalue_cast<QByteArray>(data);
auto responseType = options.property("responseType").toString().toLower();
if (responseType.isEmpty()) {
responseType = "text";
}
Promise completed = jsPromiseReady(makePromise(__FUNCTION__), scope, callback);
Promise decompressed = decompressBytes(dataByteArray);
if (responseType == "arraybuffer") {
decompressed->ready(completed);
} else {
decompressed->ready([=](QString error, QVariantMap result) {
Promise converted = convertBytes(result.value("data").toByteArray(), responseType);
converted->mixin(result);
converted->ready(completed);
});
}
}
namespace {
const int32_t DEFAULT_GZIP_COMPRESSION_LEVEL = -1;
const int32_t MAX_GZIP_COMPRESSION_LEVEL = 9;
}
void AssetScriptingInterface::compressData(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto data = options.property("data").isValid() ? options.property("data") : options;
QByteArray dataByteArray = data.isString() ? data.toString().toUtf8() : qscriptvalue_cast<QByteArray>(data);
int level = options.property("level").isNumber() ? options.property("level").toInt32() : DEFAULT_GZIP_COMPRESSION_LEVEL;
JS_VERIFY(level >= DEFAULT_GZIP_COMPRESSION_LEVEL || level <= MAX_GZIP_COMPRESSION_LEVEL, QString("invalid .level %1").arg(level));
jsPromiseReady(compressBytes(dataByteArray, level), scope, callback);
}
void AssetScriptingInterface::putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto compress = options.property("compress").toBool() || options.property("compressed").toBool();
auto data = options.isObject() ? options.property("data") : options;
auto rawPath = options.property("path").toString();
auto path = AssetUtils::getATPUrl(rawPath).path();
QByteArray dataByteArray = data.isString() ? data.toString().toUtf8() : qscriptvalue_cast<QByteArray>(data);
JS_VERIFY(path.isEmpty() || AssetUtils::isValidFilePath(path),
QString("expected valid ATP file path '%1' ('%2')").arg(rawPath).arg(path));
JS_VERIFY(dataByteArray.size() > 0,
QString("expected non-zero .data (got %1 / #%2 bytes)").arg(data.toVariant().typeName()).arg(dataByteArray.size()));
// [compressed] => uploaded to server => [mapped to path]
Promise prepared = makePromise("putAsset::prepared");
Promise uploaded = makePromise("putAsset::uploaded");
Promise completed = makePromise("putAsset::completed");
jsPromiseReady(completed, scope, callback);
if (compress) {
Promise compress = compressBytes(dataByteArray, DEFAULT_GZIP_COMPRESSION_LEVEL);
compress->ready(prepared);
} else {
prepared->resolve({{ "data", dataByteArray }});
}
prepared->fail(completed);
prepared->then([=](QVariantMap result) {
Promise upload = uploadBytes(result.value("data").toByteArray());
upload->mixin(result);
upload->ready(uploaded);
});
uploaded->fail(completed);
if (path.isEmpty()) {
uploaded->then(completed);
} else {
uploaded->then([=](QVariantMap result) {
QString hash = result.value("hash").toString();
if (!AssetUtils::isValidHash(hash)) {
completed->reject("path mapping requested, but did not receive valid hash", result);
} else {
Promise link = symlinkAsset(hash, path);
link->mixin(result);
link->ready(completed);
}
});
}
}
void AssetScriptingInterface::queryCacheMeta(QScriptValue options, QScriptValue scope, QScriptValue callback) {
QString url = options.isString() ? options.toString() : options.property("url").toString();
JS_VERIFY(QUrl(url).isValid(), QString("Invalid URL '%1'").arg(url));
jsPromiseReady(Parent::queryCacheMeta(url), scope, callback);
}
void AssetScriptingInterface::loadFromCache(QScriptValue options, QScriptValue scope, QScriptValue callback) {
QString url, responseType;
bool decompress = false;
if (options.isString()) {
url = options.toString();
responseType = "text";
} else {
url = options.property("url").toString();
responseType = options.property("responseType").isValid() ? options.property("responseType").toString() : "text";
decompress = options.property("decompress").toBool() || options.property("compressed").toBool();
}
JS_VERIFY(QUrl(url).isValid(), QString("Invalid URL '%1'").arg(url));
JS_VERIFY(RESPONSE_TYPES.contains(responseType),
QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | ")));
jsPromiseReady(Parent::loadFromCache(url, decompress, responseType), scope, callback);
}
bool AssetScriptingInterface::canWriteCacheValue(const QUrl& url) {
auto scriptEngine = qobject_cast<ScriptEngine*>(engine());
if (!scriptEngine) {
qCDebug(scriptengine) << __FUNCTION__ << "invalid script engine" << url;
return false;
}
// allow cache writes only from Client, EntityServer and Agent scripts
bool isAllowedContext = (
scriptEngine->isClientScript() ||
scriptEngine->isAgentScript() ||
scriptEngine->isEntityServerScript()
);
if (!isAllowedContext) {
qCDebug(scriptengine) << __FUNCTION__ << "invalid context" << scriptEngine->getContext() << url;
return false;
}
return true;
}
void AssetScriptingInterface::saveToCache(QScriptValue options, QScriptValue scope, QScriptValue callback) {
JS_VERIFY(options.isObject(), QString("expected options object as first parameter not: %1").arg(options.toVariant().typeName()));
QString url = options.property("url").toString();
QByteArray data = qscriptvalue_cast<QByteArray>(options.property("data"));
QVariantMap headers = qscriptvalue_cast<QVariantMap>(options.property("headers"));
saveToCache(url, data, headers, scope, callback);
}
void AssetScriptingInterface::saveToCache(const QUrl& rawURL, const QByteArray& data, const QVariantMap& metadata, QScriptValue scope, QScriptValue callback) {
QUrl url = rawURL;
if (url.path().isEmpty() && !data.isEmpty()) {
// generate a valid ATP URL from the data -- appending any existing fragment or querystring values
auto atpURL = AssetUtils::getATPUrl(hashDataHex(data));
atpURL.setQuery(url.query());
atpURL.setFragment(url.fragment());
qCDebug(scriptengine) << "autogenerated ATP URL" << url << "=>" << atpURL;
url = atpURL;
}
auto hash = AssetUtils::extractAssetHash(url.toDisplayString());
JS_VERIFY(url.isValid(), QString("Invalid URL '%1'").arg(url.toString()));
JS_VERIFY(canWriteCacheValue(url), "Invalid cache write URL: " + url.toString());
JS_VERIFY(url.scheme() == "atp" || url.scheme() == "cache", "only 'atp' and 'cache' URL schemes supported");
JS_VERIFY(hash.isEmpty() || hash == hashDataHex(data), QString("invalid checksum hash for atp:HASH style URL (%1 != %2)").arg(hash, hashDataHex(data)));
qCDebug(scriptengine) << "saveToCache" << url.toDisplayString() << data << hash << metadata;
jsPromiseReady(Parent::saveToCache(url, data, metadata), scope, callback);
}