Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -396,6 +396,11 @@ [mod] enabledmods = "mod public" +[modio.v1] +baseurl = "https://api.mod.io/v1" +api_key = "acf8fc07e3a8e9228ef4d5704c1659a1" +nameid = "0ad" + [network] duplicateplayernames = false ; Rename joining player to "User (2)" if "User" is already connected, otherwise prohibit join. lateobservers = everyone ; Allow observers to join the game after it started. Possible values: everyone, buddies, disabled. Index: binaries/data/mods/mod/gui/modmod/modmod.xml =================================================================== --- binaries/data/mods/mod/gui/modmod/modmod.xml +++ binaries/data/mods/mod/gui/modmod/modmod.xml @@ -187,7 +187,7 @@ Cancel - closePage(); + warn(uneval(Engine.ModIoGetMods())); Index: source/ps/ModIo.h =================================================================== --- /dev/null +++ source/ps/ModIo.h @@ -0,0 +1,61 @@ +/* Copyright (C) 2017 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#ifndef INCLUDED_MODIO +#define INCLUDED_MODIO + +#include + +#include "lib/external_libraries/curl.h" + +#include "scriptinterface/ScriptInterface.h" + +class ModIo +{ + NONCOPYABLE(ModIo); +public: + ModIo(); + ~ModIo(); + + const std::vector>& GetMods(const ScriptInterface& scriptinterface); + +private: + static size_t ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp); + + bool ParseGameIdResponse(const ScriptInterface& scriptInterface); + bool ParseModsResponse(const ScriptInterface& scriptInterface); + + void PrintMods(); + + // Url parts + std::string m_BaseUrl; + std::string m_GamesRequest; + std::string m_GameId; + + // Query parameters + std::string m_ApiKey; + std::string m_IdQuery; + + CURL* m_Curl; + curl_slist* m_Headers; + char m_ErrorBuffer[CURL_ERROR_SIZE]; + std::string m_ResponseData; + + std::vector> m_ModData; +}; + +#endif // INCLUDED_MODIO Index: source/ps/ModIo.cpp =================================================================== --- /dev/null +++ source/ps/ModIo.cpp @@ -0,0 +1,277 @@ +/* Copyright (C) 2017 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +// TODO: This looks like it might be useful to others, MIT? + +// TODO: Comment on why we handle things this way (not relying on user input, not trusting the remote too much, etc.) + +#include "precompiled.h" + +#include "ModIo.h" + +#include "ps/CLogger.h" +#include "ps/ConfigDB.h" +#include "scriptinterface/ScriptConversions.h" + +ModIo::ModIo() + : m_GamesRequest("/games") +{ + CFG_GET_VAL("modio.v1.baseurl", m_BaseUrl); + { + std::string api_key; + CFG_GET_VAL("modio.v1.api_key", api_key); + m_ApiKey = "api_key=" + api_key; + } + { + std::string nameid; + CFG_GET_VAL("modio.v1.nameid", nameid); + m_IdQuery = "nameid="+nameid; + } + + // Initialise everything except Win32 sockets (because our networking + // system already inits those) + curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32); +// TODO we should only do this in one single place (same for shutdown, currently we do it here and in the userreporter) + + m_Curl = curl_easy_init(); + ENSURE(m_Curl); + + // TODO: Do we actually care? + // Capture error messages + curl_easy_setopt(m_Curl, CURLOPT_ERRORBUFFER, m_ErrorBuffer); + + // Disable signal handlers (required for multithreaded applications) + curl_easy_setopt(m_Curl, CURLOPT_NOSIGNAL, 1L); + + // To minimise security risks, don't support redirects + curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L); + + // Set IO callbacks + curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback); + curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this); + + m_Headers = NULL; + std::string ua = "User-Agent: 0ad "; + ua += curl_version(); + ua += " (https://play0ad.com/)"; + m_Headers = curl_slist_append(m_Headers, ua.c_str()); + // TODO more? + curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers); + + + // we only accept indices from JS when downloading something later on (security) +} + +ModIo::~ModIo() +{ + curl_slist_free_all(m_Headers); + curl_easy_cleanup(m_Curl); + curl_global_cleanup(); // TODO +} + +size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp) +{ + ModIo* self = static_cast(userp); + + self->m_ResponseData += std::string((char*)buffer, (char*)buffer+size*nmemb); + + return size*nmemb; +} + +const std::vector>& ModIo::GetMods(const ScriptInterface& scriptInterface) +{ + m_ModData.clear(); + + // Get the game id if we didn't fetch it already + if (m_GameId.empty()) + { + std::string url = m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery; + curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str()); + CURLcode err = curl_easy_perform(m_Curl); + + if (err != CURLE_OK) + { + LOGERROR("Server said no: %d", err); + return m_ModData; + } + + if (!ParseGameIdResponse(scriptInterface)) + return m_ModData; + } + + // Get the actual mods + std::string url = m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey; + curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str()); + CURLcode err = curl_easy_perform(m_Curl); + if (err != CURLE_OK) + { + LOGERROR("Server said no to mods %d", err); + return m_ModData; + } + + if (!ParseModsResponse(scriptInterface)) + m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data + + PrintMods(); + + return m_ModData; +} + +#define FAIL(...) STMT(LOGERROR(__VA_ARGS__); return false;) + +/** + * Parses the current content of m_ResponseData to extract m_GameId. + * + * The JSON data is expected to look like + * { "data": [{"id": 42, ...}, ...], ... } + * where we are only interested in the value of the id property. + * + * @returns true iff it successfully parsed the id. + */ +bool ModIo::ParseGameIdResponse(const ScriptInterface& scriptInterface) +{ + JSContext* cx = scriptInterface.GetContext(); + JS::RootedValue gameResponse(cx); + + // What the data is expected to look like, and what we need. + // There is also a result_count property at the same level as data, which should always be 1 following + // our query, but maybe we should check that (nah) + if (!scriptInterface.ParseJSON(m_ResponseData, &gameResponse)) + FAIL("Failed to parse response as JSON."); + + m_ResponseData.clear(); + + JS::RootedObject gameResponseObj(cx, gameResponse.toObjectOrNull()); + // TODO handle it being null, or does JS_GetProperty do that for us? + JS::RootedValue dataVal(cx); + if (!JS_GetProperty(cx, gameResponseObj, "data", &dataVal)) + FAIL("data property not in response."); + + // [{"id": 42, ...}, ...] + if (!dataVal.isObject()) + FAIL("data property not an object"); + + JS::RootedObject data(cx); + data = &dataVal.toObject(); + u32 length; + if (!JS_IsArrayObject(cx, data) || !JS_GetArrayLength(cx, data, &length) || !length) + FAIL("data property not an array with at least one element"); + + // {"id": 42, ...} + JS::RootedValue first(cx); + if (!JS_GetElement(cx, data, 0, &first)) + FAIL("couldn't get first element."); + + int id = -1; + if (!ScriptInterface::FromJSProperty(cx, first, "id", id)) + FAIL("couldn't get id"); + + m_GameId = "/" + std::to_string(id); + return true; +} + +/** + * Parses the current content of m_ResponseData into m_ModData. + * + * The JSON data is expected to look like + * { data: [modobj1, modobj2, ...], ... (including result_count) } + * where modobjN has the following structure + * { homepage: "url", name: "displayname", nameid: "short-non-whitespace-name", + * summary: "short desc.", modfile: { version: "1.2.4", filename: "asdf.zip", + * filehash: "md5sum", filesize: 1234, download: "someurl" }, ... }. + * Only the listed properties are of interest to consumers, and we flatten + * the modfile structure as that simplifies handling and there are no conflicts. + */ +bool ModIo::ParseModsResponse(const ScriptInterface& scriptInterface) +{ + JSContext* cx = scriptInterface.GetContext(); + JS::RootedValue modResponse(cx); + + if (!scriptInterface.ParseJSON(m_ResponseData, &modResponse)) + FAIL("Failed to parse response as JSON."); + + m_ResponseData.clear(); + + JS::RootedObject modResponseObj(cx, modResponse.toObjectOrNull()); + // TODO handle it being null, or does JS_GetProperty do that for us? + JS::RootedValue dataVal(cx); + if (!JS_GetProperty(cx, modResponseObj, "data", &dataVal)) + FAIL("data property not in response."); + + // [modobj1, modobj2, ... ] + if (!dataVal.isObject()) + FAIL("data property not an object"); + + JS::RootedObject data(cx); + data = &dataVal.toObject(); + u32 length; + if (!JS_IsArrayObject(cx, data) || !JS_GetArrayLength(cx, data, &length) || !length) + FAIL("data property not an array with at least one element"); + + m_ModData.clear(); + m_ModData.reserve(length); + + for (u32 i = 0; i < length; ++i) + { + JS::RootedValue el(cx); + if (!JS_GetElement(cx, data, i, &el) || !el.isObject()) + FAIL("Failed to get array element object"); + + m_ModData.emplace_back(); + // TODO: Currently the homepage field does not contain a non-null value for any entry. + for (const std::string& prop : {"name", "nameid", "summary"}) + { + std::string val; + ScriptInterface::FromJSProperty(cx, el, prop.c_str(), val); + m_ModData.back().emplace(prop, val); + } + + // Now copy over the modfile part, but without the pointless substructure + JS::RootedObject elObj(cx); + elObj = &el.toObject(); + JS::RootedValue modFile(cx); + if (!JS_GetProperty(cx, elObj, "modfile", &modFile)) + FAIL("Failed to get modfile data"); + + for (const std::string& prop : {"version", "filename", "filehash", "filesize", "download"}) + { + std::string val; + ScriptInterface::FromJSProperty(cx, modFile, prop.c_str(), val); + m_ModData.back().emplace(prop, val); + } + } + + return true; +} + +#undef FAIL + +// TODO: nuke this at some point +void ModIo::PrintMods() +{ + printf("[\n"); + for (const auto& a : m_ModData) + { + printf(" {\n"); + for (const auto& b : a) + { + printf(" \"%s\": \"%s\",\n", b.first.c_str(), b.second.c_str()); + } + printf(" },\n"); + } + printf("]\n"); +} Index: source/ps/scripting/JSInterface_Mod.h =================================================================== --- source/ps/scripting/JSInterface_Mod.h +++ source/ps/scripting/JSInterface_Mod.h @@ -26,6 +26,8 @@ JS::Value GetAvailableMods(ScriptInterface::CxPrivate* pCxPrivate); void RestartEngine(ScriptInterface::CxPrivate* pCxPrivate); void SetMods(ScriptInterface::CxPrivate* pCxPrivate, const std::vector& mods); + + JS::Value ModIoGetMods(ScriptInterface::CxPrivate* pCxPrivate); } #endif Index: source/ps/scripting/JSInterface_Mod.cpp =================================================================== --- source/ps/scripting/JSInterface_Mod.cpp +++ source/ps/scripting/JSInterface_Mod.cpp @@ -27,6 +27,7 @@ #include "ps/GameSetup/GameSetup.h" #include "ps/GameSetup/Paths.h" #include "ps/Mod.h" +#include "ps/ModIo.h" #include "ps/Profile.h" #include "ps/scripting/JSInterface_Mod.h" @@ -123,9 +124,34 @@ g_modsLoaded = mods; } +JS::Value JSI_Mod::ModIoGetMods(ScriptInterface::CxPrivate* pCxPrivate) +{ + ScriptInterface* scriptInterface = pCxPrivate->pScriptInterface; + JSContext* cx = scriptInterface->GetContext(); + JSAutoRequest rq(cx); + + ModIo modIo; + + JS::RootedValue mods(cx); + scriptInterface->Eval("([])", &mods); + for (const std::map& mod : modIo.GetMods(*scriptInterface)) + { + JS::RootedValue m(cx); + scriptInterface->Eval("({})", &m); + for (const std::pair& prop : mod) + scriptInterface->SetProperty(m, prop.first.c_str(), prop.second, true); + + scriptInterface->CallFunctionVoid(mods, "push", m); + } + + return mods; +} + void JSI_Mod::RegisterScriptFunctions(const ScriptInterface& scriptInterface) { scriptInterface.RegisterFunction("GetAvailableMods"); scriptInterface.RegisterFunction("RestartEngine"); scriptInterface.RegisterFunction, &JSI_Mod::SetMods>("SetMods"); + + scriptInterface.RegisterFunction("ModIoGetMods"); }