Add the streamer-tools API client, with a real HTTP backend per platform
Implements the two read-key-scoped calls in apps/server/src/obs/plugin.routes.ts: GET /api/obs/:slug/slots and POST /api/obs/:slug/token. Three pieces, all in core/ with no OBS dependency: - stplugin::json -- a small, strict JSON reader. Hand-rolled rather than vendoring nlohmann because the only JSON this plugin ever sees is two fixed-shape responses from its own server, and the parser has to build on three platforms with no package-manager step in CI. It never throws, bounds its recursion (kMaxDepth=32) so a hostile response cannot overflow the stack inside OBS, rejects trailing garbage, and returns the caller's fallback for wrong-typed access instead of aborting. - stplugin::HttpClient -- a two-method injectable interface, with libcurl behind it on Linux/macOS and WinHTTP on Windows. WinHTTP rather than curl on Windows because it ships with the OS and does TLS through SChannel: the self-hosted winvm-builder runner has no package manager, and per the scaffold README does not even have cmake preinstalled. Both backends cap the response body at 4 MiB, keep TLS verification on (the read key is a credential), and honour a whole-request timeout. - stplugin::ApiClient -- maps the responses onto an ApiStatus enum that distinguishes NotFound (404), Unavailable (503), NetworkError, MalformedResponse and InvalidConfig. It deliberately does not claim to know whether a 404 was a wrong key or an unknown slug, because the server deliberately does not say. Server URLs are normalised the way an operator actually pastes them, defaulting to https so the read key is never sent in the clear by accident, and redactedUrl() exists so a URL can be logged without the key. Tests (279 checks across two new suites) run at two levels: a fake HttpClient covering every response and error branch, and a real loopback HTTP server on 127.0.0.1 driving the actual platform backend -- so libcurl on Linux/macOS and WinHTTP on Windows are each exercised in CI rather than assumed. The loopback cases deliberately include the ones that must not hang OBS: a truncated JSON body, a connection accepted and closed without a reply, non-HTTP garbage, a dead port, and a stalled server that has to be cut off by the client's own timeout. Verified locally on Ubuntu 24.04: ctest --test-dir build --output-on-failure -> 4/4 passed test_json: 158 checks passed test_api_client: 121 checks passed Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - streamer-tools API client
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
#include "stplugin/api_client.h"
|
||||
|
||||
#include "stplugin/json.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace stplugin {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(const std::string &s)
|
||||
{
|
||||
std::size_t begin = 0;
|
||||
std::size_t end = s.size();
|
||||
auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
|
||||
while (begin < end && is_space(s[begin]))
|
||||
++begin;
|
||||
while (end > begin && is_space(s[end - 1]))
|
||||
--end;
|
||||
return s.substr(begin, end - begin);
|
||||
}
|
||||
|
||||
/// Map a completed HTTP response onto the shared status codes. Returns
|
||||
/// ApiStatus::Ok when the caller should go on to parse the body.
|
||||
ApiStatus classify(const HttpResponse &response, std::string &message)
|
||||
{
|
||||
if (!response.ok()) {
|
||||
message = response.network_error;
|
||||
return ApiStatus::NetworkError;
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300)
|
||||
return ApiStatus::Ok;
|
||||
if (response.status == 404) {
|
||||
message = "unknown room slug, or the read key is wrong or has been rotated";
|
||||
return ApiStatus::NotFound;
|
||||
}
|
||||
if (response.status == 503) {
|
||||
message = "the streamer-tools server has no LiveKit credentials configured";
|
||||
return ApiStatus::Unavailable;
|
||||
}
|
||||
message = "HTTP " + std::to_string(response.status);
|
||||
return ApiStatus::HttpError;
|
||||
}
|
||||
|
||||
std::string buildUrl(const ConnectionConfig &config, const char *suffix)
|
||||
{
|
||||
return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" +
|
||||
urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *describeApiStatus(ApiStatus status)
|
||||
{
|
||||
switch (status) {
|
||||
case ApiStatus::Ok: return "ok";
|
||||
case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required";
|
||||
case ApiStatus::NetworkError: return "could not reach the streamer-tools server";
|
||||
case ApiStatus::NotFound: return "room not found, or the read key is wrong";
|
||||
case ApiStatus::Unavailable: return "server has no LiveKit configured";
|
||||
case ApiStatus::HttpError: return "unexpected response from the streamer-tools server";
|
||||
case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server";
|
||||
}
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
ApiClient::ApiClient(std::shared_ptr<HttpClient> http) : http_(std::move(http)) {}
|
||||
|
||||
std::string ApiClient::normalizeServerUrl(const std::string &raw)
|
||||
{
|
||||
std::string url = trim(raw);
|
||||
if (url.empty())
|
||||
return url;
|
||||
|
||||
// A bare "streamers.example.com" is what an operator will paste half the
|
||||
// time. Defaulting to https (never http) keeps the read key off the wire
|
||||
// in the clear.
|
||||
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
|
||||
if (!has_scheme)
|
||||
url = "https://" + url;
|
||||
|
||||
while (!url.empty() && url.back() == '/')
|
||||
url.pop_back();
|
||||
|
||||
// "https://" with nothing after it is not a server.
|
||||
if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" ||
|
||||
url == "https://" || url == "http://")
|
||||
return std::string();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
std::string ApiClient::redactedUrl(const std::string &url)
|
||||
{
|
||||
const std::size_t at = url.find("key=");
|
||||
if (at == std::string::npos)
|
||||
return url;
|
||||
const std::size_t value = at + 4;
|
||||
std::size_t end = url.find('&', value);
|
||||
if (end == std::string::npos)
|
||||
end = url.size();
|
||||
return url.substr(0, value) + "***" + url.substr(end);
|
||||
}
|
||||
|
||||
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
|
||||
{
|
||||
SlotsResult result;
|
||||
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
|
||||
result.status = ApiStatus::InvalidConfig;
|
||||
result.message = describeApiStatus(ApiStatus::InvalidConfig);
|
||||
return result;
|
||||
}
|
||||
|
||||
HttpRequest request;
|
||||
request.method = "GET";
|
||||
request.url = buildUrl(config, "/slots");
|
||||
|
||||
const HttpResponse response = http_->send(request);
|
||||
const ApiStatus status = classify(response, result.message);
|
||||
if (status != ApiStatus::Ok) {
|
||||
result.status = status;
|
||||
return result;
|
||||
}
|
||||
|
||||
const json::Value root = json::parse(response.body);
|
||||
const json::Value &slots = root["slots"];
|
||||
if (!root.isObject() || !slots.isArray()) {
|
||||
result.status = ApiStatus::MalformedResponse;
|
||||
result.message = "expected a JSON object with a \"slots\" array";
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const json::Value &entry : slots.elements()) {
|
||||
// A slot without an identity is unusable -- it is what the session
|
||||
// wrapper subscribes by -- so skip it rather than surfacing a
|
||||
// dropdown row that can never connect. Anything else is best-effort:
|
||||
// a missing displayName falls back to the identity exactly as the
|
||||
// server itself does.
|
||||
const std::string identity = entry["identity"].asString();
|
||||
if (identity.empty())
|
||||
continue;
|
||||
SlotInfo slot;
|
||||
slot.identity = identity;
|
||||
slot.display_name = entry["displayName"].asString(identity);
|
||||
if (slot.display_name.empty())
|
||||
slot.display_name = identity;
|
||||
slot.live = entry["live"].asBool(false);
|
||||
result.slots.push_back(std::move(slot));
|
||||
}
|
||||
|
||||
result.status = ApiStatus::Ok;
|
||||
return result;
|
||||
}
|
||||
|
||||
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
|
||||
{
|
||||
TokenResult result;
|
||||
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
|
||||
result.status = ApiStatus::InvalidConfig;
|
||||
result.message = describeApiStatus(ApiStatus::InvalidConfig);
|
||||
return result;
|
||||
}
|
||||
|
||||
HttpRequest request;
|
||||
request.method = "POST";
|
||||
request.url = buildUrl(config, "/token");
|
||||
request.content_type = "application/json";
|
||||
request.body = "{}";
|
||||
|
||||
const HttpResponse response = http_->send(request);
|
||||
const ApiStatus status = classify(response, result.message);
|
||||
if (status != ApiStatus::Ok) {
|
||||
result.status = status;
|
||||
return result;
|
||||
}
|
||||
|
||||
const json::Value root = json::parse(response.body);
|
||||
const std::string token = root["lkToken"].asString();
|
||||
const std::string ws_url = root["wsUrl"].asString();
|
||||
if (!root.isObject() || token.empty() || ws_url.empty()) {
|
||||
result.status = ApiStatus::MalformedResponse;
|
||||
result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\"";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.lk_token = token;
|
||||
result.ws_url = ws_url;
|
||||
result.identity = root["identity"].asString();
|
||||
result.status = ApiStatus::Ok;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace stplugin
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
#include "stplugin/http.h"
|
||||
|
||||
namespace stplugin {
|
||||
|
||||
// Hand-rolled rather than curl_easy_escape so the WinHTTP backend gets the
|
||||
// same behaviour, and so this is testable without a live HTTP client.
|
||||
// Unreserved set per RFC 3986 section 2.3.
|
||||
std::string urlEncode(const std::string &value)
|
||||
{
|
||||
static const char *kHex = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(value.size());
|
||||
for (const char raw : value) {
|
||||
const unsigned char c = static_cast<unsigned char>(raw);
|
||||
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
|
||||
if (unreserved) {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(kHex[c >> 4]);
|
||||
out.push_back(kHex[c & 0x0F]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace stplugin
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS)
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
#include "stplugin/http.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
|
||||
namespace stplugin {
|
||||
|
||||
namespace {
|
||||
|
||||
/// Hard cap on a response body. The two endpoints this client talks to return
|
||||
/// a few hundred bytes; anything larger is a misconfigured proxy or a wrong
|
||||
/// URL, and must not be allowed to grow OBS's heap without bound.
|
||||
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
|
||||
|
||||
struct WriteContext {
|
||||
std::string body;
|
||||
bool overflowed = false;
|
||||
};
|
||||
|
||||
std::size_t writeCallback(char *ptr, std::size_t size, std::size_t nmemb, void *userdata)
|
||||
{
|
||||
auto *ctx = static_cast<WriteContext *>(userdata);
|
||||
const std::size_t bytes = size * nmemb;
|
||||
if (ctx->body.size() + bytes > kMaxResponseBytes) {
|
||||
ctx->overflowed = true;
|
||||
return 0; // aborts the transfer with CURLE_WRITE_ERROR
|
||||
}
|
||||
ctx->body.append(ptr, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// curl_global_init is not thread-safe and must run once per process before
|
||||
/// any easy handle is created. OBS may create several sources concurrently.
|
||||
void ensureCurlGlobalInit()
|
||||
{
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); });
|
||||
}
|
||||
|
||||
class CurlHttpClient : public HttpClient {
|
||||
public:
|
||||
HttpResponse send(const HttpRequest &request) override
|
||||
{
|
||||
ensureCurlGlobalInit();
|
||||
|
||||
HttpResponse response;
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
response.network_error = "curl_easy_init failed";
|
||||
return response;
|
||||
}
|
||||
|
||||
WriteContext ctx;
|
||||
struct curl_slist *headers = nullptr;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
|
||||
// NOSIGNAL is required whenever curl is used off the main thread:
|
||||
// without it curl installs a SIGALRM handler for DNS timeouts, which
|
||||
// is process-global and would be a rude thing to do inside OBS.
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
// TLS verification stays on. The read key is a credential; sending it
|
||||
// to an unverified host is exactly the failure this must not have.
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
if (request.method == "POST") {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request.body.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(request.body.size()));
|
||||
} else if (request.method != "GET") {
|
||||
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request.method.c_str());
|
||||
}
|
||||
|
||||
if (!request.content_type.empty()) {
|
||||
const std::string header = "Content-Type: " + request.content_type;
|
||||
headers = curl_slist_append(headers, header.c_str());
|
||||
}
|
||||
// Fastify answers a bare POST with no body fine, but some proxies
|
||||
// insert an Expect: 100-continue round trip; suppress it.
|
||||
headers = curl_slist_append(headers, "Expect:");
|
||||
if (headers)
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
const CURLcode rc = curl_easy_perform(curl);
|
||||
if (rc == CURLE_OK) {
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
response.status = status;
|
||||
response.body = std::move(ctx.body);
|
||||
} else if (ctx.overflowed) {
|
||||
response.network_error = "response body exceeded 4 MiB";
|
||||
} else {
|
||||
response.network_error = curl_easy_strerror(rc);
|
||||
}
|
||||
|
||||
if (headers)
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpClient *createPlatformHttpClient()
|
||||
{
|
||||
return new CurlHttpClient();
|
||||
}
|
||||
|
||||
} // namespace stplugin
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS
|
||||
// through SChannel (so no OpenSSL to build or ship), and needs no package
|
||||
// manager on the self-hosted `winvm-builder` runner -- which, per the
|
||||
// scaffold README, is a bare VM without even cmake preinstalled.
|
||||
|
||||
#include "stplugin/http.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <winhttp.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace stplugin {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
|
||||
|
||||
std::wstring widen(const std::string &s)
|
||||
{
|
||||
if (s.empty())
|
||||
return std::wstring();
|
||||
const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
|
||||
if (needed <= 0)
|
||||
return std::wstring();
|
||||
std::wstring out(static_cast<std::size_t>(needed), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), &out[0], needed);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string lastErrorMessage(const char *what)
|
||||
{
|
||||
return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")";
|
||||
}
|
||||
|
||||
/// RAII for the three WinHTTP handle kinds, which all close the same way.
|
||||
class Handle {
|
||||
public:
|
||||
Handle() = default;
|
||||
explicit Handle(HINTERNET h) : h_(h) {}
|
||||
~Handle()
|
||||
{
|
||||
if (h_)
|
||||
WinHttpCloseHandle(h_);
|
||||
}
|
||||
Handle(const Handle &) = delete;
|
||||
Handle &operator=(const Handle &) = delete;
|
||||
|
||||
void reset(HINTERNET h)
|
||||
{
|
||||
if (h_)
|
||||
WinHttpCloseHandle(h_);
|
||||
h_ = h;
|
||||
}
|
||||
HINTERNET get() const { return h_; }
|
||||
explicit operator bool() const { return h_ != nullptr; }
|
||||
|
||||
private:
|
||||
HINTERNET h_ = nullptr;
|
||||
};
|
||||
|
||||
class WinHttpClient : public HttpClient {
|
||||
public:
|
||||
HttpResponse send(const HttpRequest &request) override
|
||||
{
|
||||
HttpResponse response;
|
||||
|
||||
const std::wstring url = widen(request.url);
|
||||
if (url.empty()) {
|
||||
response.network_error = "empty or non-UTF-8 URL";
|
||||
return response;
|
||||
}
|
||||
|
||||
URL_COMPONENTS parts{};
|
||||
parts.dwStructSize = sizeof(parts);
|
||||
wchar_t host[256] = {0};
|
||||
wchar_t path[4096] = {0};
|
||||
wchar_t extra[4096] = {0};
|
||||
parts.lpszHostName = host;
|
||||
parts.dwHostNameLength = static_cast<DWORD>(sizeof(host) / sizeof(host[0]));
|
||||
parts.lpszUrlPath = path;
|
||||
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
|
||||
parts.lpszExtraInfo = extra;
|
||||
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
|
||||
|
||||
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts)) {
|
||||
response.network_error = lastErrorMessage("WinHttpCrackUrl");
|
||||
return response;
|
||||
}
|
||||
if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) {
|
||||
response.network_error = "unsupported URL scheme";
|
||||
return response;
|
||||
}
|
||||
|
||||
Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
|
||||
if (!session) {
|
||||
response.network_error = lastErrorMessage("WinHttpOpen");
|
||||
return response;
|
||||
}
|
||||
|
||||
const DWORD timeout = static_cast<DWORD>(request.timeout_ms);
|
||||
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
|
||||
static_cast<int>(timeout), static_cast<int>(timeout));
|
||||
|
||||
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
|
||||
if (!connect) {
|
||||
response.network_error = lastErrorMessage("WinHttpConnect");
|
||||
return response;
|
||||
}
|
||||
|
||||
std::wstring target(path);
|
||||
target += extra;
|
||||
|
||||
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
|
||||
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
|
||||
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
|
||||
if (!req) {
|
||||
response.network_error = lastErrorMessage("WinHttpOpenRequest");
|
||||
return response;
|
||||
}
|
||||
|
||||
std::wstring headers;
|
||||
if (!request.content_type.empty())
|
||||
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
|
||||
|
||||
const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str();
|
||||
const DWORD header_len = headers.empty() ? 0u : static_cast<DWORD>(headers.size());
|
||||
|
||||
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
|
||||
: const_cast<char *>(request.body.data());
|
||||
const DWORD body_len = static_cast<DWORD>(request.body.size());
|
||||
|
||||
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
|
||||
response.network_error = lastErrorMessage("WinHttpSendRequest");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
|
||||
return response;
|
||||
}
|
||||
|
||||
DWORD status = 0;
|
||||
DWORD status_size = sizeof(status);
|
||||
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
|
||||
return response;
|
||||
}
|
||||
response.status = static_cast<long>(status);
|
||||
|
||||
std::string body;
|
||||
for (;;) {
|
||||
DWORD available = 0;
|
||||
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
|
||||
return response;
|
||||
}
|
||||
if (available == 0)
|
||||
break;
|
||||
if (body.size() + available > kMaxResponseBytes) {
|
||||
response.network_error = "response body exceeded 4 MiB";
|
||||
return response;
|
||||
}
|
||||
std::vector<char> chunk(available);
|
||||
DWORD read = 0;
|
||||
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReadData");
|
||||
return response;
|
||||
}
|
||||
if (read == 0)
|
||||
break;
|
||||
body.append(chunk.data(), read);
|
||||
}
|
||||
|
||||
response.body = std::move(body);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpClient *createPlatformHttpClient()
|
||||
{
|
||||
return new WinHttpClient();
|
||||
}
|
||||
|
||||
} // namespace stplugin
|
||||
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - minimal JSON reader
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
#include "stplugin/json.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace stplugin {
|
||||
namespace json {
|
||||
|
||||
namespace {
|
||||
const Value &invalidSingleton()
|
||||
{
|
||||
static const Value v;
|
||||
return v;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Value Value::makeNull()
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::Null;
|
||||
return v;
|
||||
}
|
||||
|
||||
Value Value::makeBool(bool b)
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::Bool;
|
||||
v.bool_ = b;
|
||||
return v;
|
||||
}
|
||||
|
||||
Value Value::makeNumber(double n)
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::Number;
|
||||
v.number_ = n;
|
||||
return v;
|
||||
}
|
||||
|
||||
Value Value::makeString(std::string s)
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::String;
|
||||
v.string_ = std::move(s);
|
||||
return v;
|
||||
}
|
||||
|
||||
Value Value::makeArray(std::vector<Value> a)
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::Array;
|
||||
v.array_ = std::move(a);
|
||||
return v;
|
||||
}
|
||||
|
||||
Value Value::makeObject(std::map<std::string, Value> o)
|
||||
{
|
||||
Value v;
|
||||
v.type_ = Type::Object;
|
||||
v.object_ = std::move(o);
|
||||
return v;
|
||||
}
|
||||
|
||||
const Value &Value::operator[](const std::string &key) const
|
||||
{
|
||||
if (type_ != Type::Object)
|
||||
return invalidSingleton();
|
||||
auto it = object_.find(key);
|
||||
if (it == object_.end())
|
||||
return invalidSingleton();
|
||||
return it->second;
|
||||
}
|
||||
|
||||
const Value &Value::at(std::size_t index) const
|
||||
{
|
||||
if (type_ != Type::Array || index >= array_.size())
|
||||
return invalidSingleton();
|
||||
return array_[index];
|
||||
}
|
||||
|
||||
std::size_t Value::size() const
|
||||
{
|
||||
if (type_ == Type::Array)
|
||||
return array_.size();
|
||||
if (type_ == Type::Object)
|
||||
return object_.size();
|
||||
if (type_ == Type::String)
|
||||
return string_.size();
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string Value::asString(const std::string &fallback) const
|
||||
{
|
||||
return type_ == Type::String ? string_ : fallback;
|
||||
}
|
||||
|
||||
bool Value::asBool(bool fallback) const
|
||||
{
|
||||
return type_ == Type::Bool ? bool_ : fallback;
|
||||
}
|
||||
|
||||
double Value::asNumber(double fallback) const
|
||||
{
|
||||
return type_ == Type::Number ? number_ : fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string &text) : s_(text) {}
|
||||
|
||||
bool parseDocument(Value &out)
|
||||
{
|
||||
skipWs();
|
||||
if (!parseValue(out, 0))
|
||||
return false;
|
||||
skipWs();
|
||||
// Trailing content is an error: "{}garbage" must not silently parse
|
||||
// as an empty object.
|
||||
return pos_ == s_.size();
|
||||
}
|
||||
|
||||
private:
|
||||
const std::string &s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
char peek() const { return s_[pos_]; }
|
||||
|
||||
void skipWs()
|
||||
{
|
||||
while (!eof()) {
|
||||
const char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
|
||||
++pos_;
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool literal(const char *lit)
|
||||
{
|
||||
const std::size_t n = std::strlen(lit);
|
||||
if (s_.compare(pos_, n, lit) != 0)
|
||||
return false;
|
||||
pos_ += n;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseValue(Value &out, int depth)
|
||||
{
|
||||
if (depth > kMaxDepth)
|
||||
return false;
|
||||
if (eof())
|
||||
return false;
|
||||
|
||||
switch (peek()) {
|
||||
case '{':
|
||||
return parseObject(out, depth);
|
||||
case '[':
|
||||
return parseArray(out, depth);
|
||||
case '"': {
|
||||
std::string str;
|
||||
if (!parseString(str))
|
||||
return false;
|
||||
out = Value::makeString(std::move(str));
|
||||
return true;
|
||||
}
|
||||
case 't':
|
||||
if (!literal("true"))
|
||||
return false;
|
||||
out = Value::makeBool(true);
|
||||
return true;
|
||||
case 'f':
|
||||
if (!literal("false"))
|
||||
return false;
|
||||
out = Value::makeBool(false);
|
||||
return true;
|
||||
case 'n':
|
||||
if (!literal("null"))
|
||||
return false;
|
||||
out = Value::makeNull();
|
||||
return true;
|
||||
default:
|
||||
return parseNumber(out);
|
||||
}
|
||||
}
|
||||
|
||||
bool parseObject(Value &out, int depth)
|
||||
{
|
||||
++pos_; // '{'
|
||||
std::map<std::string, Value> members;
|
||||
skipWs();
|
||||
if (!eof() && peek() == '}') {
|
||||
++pos_;
|
||||
out = Value::makeObject(std::move(members));
|
||||
return true;
|
||||
}
|
||||
for (;;) {
|
||||
skipWs();
|
||||
std::string key;
|
||||
if (!parseString(key))
|
||||
return false;
|
||||
skipWs();
|
||||
if (eof() || peek() != ':')
|
||||
return false;
|
||||
++pos_;
|
||||
skipWs();
|
||||
Value v;
|
||||
if (!parseValue(v, depth + 1))
|
||||
return false;
|
||||
members[key] = std::move(v);
|
||||
skipWs();
|
||||
if (eof())
|
||||
return false;
|
||||
if (peek() == ',') {
|
||||
++pos_;
|
||||
continue;
|
||||
}
|
||||
if (peek() == '}') {
|
||||
++pos_;
|
||||
out = Value::makeObject(std::move(members));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseArray(Value &out, int depth)
|
||||
{
|
||||
++pos_; // '['
|
||||
std::vector<Value> items;
|
||||
skipWs();
|
||||
if (!eof() && peek() == ']') {
|
||||
++pos_;
|
||||
out = Value::makeArray(std::move(items));
|
||||
return true;
|
||||
}
|
||||
for (;;) {
|
||||
skipWs();
|
||||
Value v;
|
||||
if (!parseValue(v, depth + 1))
|
||||
return false;
|
||||
items.push_back(std::move(v));
|
||||
skipWs();
|
||||
if (eof())
|
||||
return false;
|
||||
if (peek() == ',') {
|
||||
++pos_;
|
||||
continue;
|
||||
}
|
||||
if (peek() == ']') {
|
||||
++pos_;
|
||||
out = Value::makeArray(std::move(items));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseHex4(unsigned &out)
|
||||
{
|
||||
if (pos_ + 4 > s_.size())
|
||||
return false;
|
||||
unsigned value = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const char c = s_[pos_ + static_cast<std::size_t>(i)];
|
||||
unsigned digit;
|
||||
if (c >= '0' && c <= '9')
|
||||
digit = static_cast<unsigned>(c - '0');
|
||||
else if (c >= 'a' && c <= 'f')
|
||||
digit = static_cast<unsigned>(c - 'a') + 10u;
|
||||
else if (c >= 'A' && c <= 'F')
|
||||
digit = static_cast<unsigned>(c - 'A') + 10u;
|
||||
else
|
||||
return false;
|
||||
value = (value << 4) | digit;
|
||||
}
|
||||
pos_ += 4;
|
||||
out = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void appendUtf8(std::string &out, unsigned cp)
|
||||
{
|
||||
if (cp < 0x80) {
|
||||
out.push_back(static_cast<char>(cp));
|
||||
} else if (cp < 0x800) {
|
||||
out.push_back(static_cast<char>(0xC0u | (cp >> 6)));
|
||||
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
|
||||
} else if (cp < 0x10000) {
|
||||
out.push_back(static_cast<char>(0xE0u | (cp >> 12)));
|
||||
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
|
||||
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
|
||||
} else {
|
||||
out.push_back(static_cast<char>(0xF0u | (cp >> 18)));
|
||||
out.push_back(static_cast<char>(0x80u | ((cp >> 12) & 0x3Fu)));
|
||||
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
|
||||
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
|
||||
}
|
||||
}
|
||||
|
||||
bool parseString(std::string &out)
|
||||
{
|
||||
if (eof() || peek() != '"')
|
||||
return false;
|
||||
++pos_;
|
||||
out.clear();
|
||||
for (;;) {
|
||||
if (eof())
|
||||
return false; // unterminated string
|
||||
const unsigned char c = static_cast<unsigned char>(s_[pos_]);
|
||||
if (c == '"') {
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
if (c == '\\') {
|
||||
++pos_;
|
||||
if (eof())
|
||||
return false;
|
||||
const char esc = s_[pos_++];
|
||||
switch (esc) {
|
||||
case '"': out.push_back('"'); break;
|
||||
case '\\': out.push_back('\\'); break;
|
||||
case '/': out.push_back('/'); break;
|
||||
case 'b': out.push_back('\b'); break;
|
||||
case 'f': out.push_back('\f'); break;
|
||||
case 'n': out.push_back('\n'); break;
|
||||
case 'r': out.push_back('\r'); break;
|
||||
case 't': out.push_back('\t'); break;
|
||||
case 'u': {
|
||||
unsigned cp = 0;
|
||||
if (!parseHex4(cp))
|
||||
return false;
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
// High surrogate: a low surrogate must follow.
|
||||
if (pos_ + 1 < s_.size() && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') {
|
||||
pos_ += 2;
|
||||
unsigned lo = 0;
|
||||
if (!parseHex4(lo))
|
||||
return false;
|
||||
if (lo < 0xDC00 || lo > 0xDFFF)
|
||||
return false;
|
||||
cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
return false; // lone low surrogate
|
||||
}
|
||||
appendUtf8(out, cp);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c < 0x20)
|
||||
return false; // raw control character
|
||||
out.push_back(static_cast<char>(c));
|
||||
++pos_;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseNumber(Value &out)
|
||||
{
|
||||
const std::size_t start = pos_;
|
||||
if (!eof() && peek() == '-')
|
||||
++pos_;
|
||||
if (eof())
|
||||
return false;
|
||||
if (peek() == '0') {
|
||||
++pos_;
|
||||
} else if (peek() >= '1' && peek() <= '9') {
|
||||
while (!eof() && peek() >= '0' && peek() <= '9')
|
||||
++pos_;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!eof() && peek() == '.') {
|
||||
++pos_;
|
||||
if (eof() || peek() < '0' || peek() > '9')
|
||||
return false;
|
||||
while (!eof() && peek() >= '0' && peek() <= '9')
|
||||
++pos_;
|
||||
}
|
||||
if (!eof() && (peek() == 'e' || peek() == 'E')) {
|
||||
++pos_;
|
||||
if (!eof() && (peek() == '+' || peek() == '-'))
|
||||
++pos_;
|
||||
if (eof() || peek() < '0' || peek() > '9')
|
||||
return false;
|
||||
while (!eof() && peek() >= '0' && peek() <= '9')
|
||||
++pos_;
|
||||
}
|
||||
const std::string token = s_.substr(start, pos_ - start);
|
||||
// strtod is locale-sensitive for the decimal separator, but the
|
||||
// grammar above only ever hands it ASCII digits with a '.', and OBS
|
||||
// does not switch the C locale away from "C". Using strtod rather
|
||||
// than std::stod keeps this noexcept.
|
||||
out = Value::makeNumber(std::strtod(token.c_str(), nullptr));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Value parse(const std::string &text)
|
||||
{
|
||||
Parser p(text);
|
||||
Value v;
|
||||
if (!p.parseDocument(v))
|
||||
return Value::invalid();
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace json
|
||||
} // namespace stplugin
|
||||
Reference in New Issue
Block a user