diff --git a/docs/Doxyfile b/docs/Doxyfile index 7c08c4f093a..6e95fd0f7a4 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -62,4 +62,8 @@ INPUT = ../README.md \ HTML_EXTRA_STYLESHEET += doc-styles.css # extra js +HTML_EXTRA_FILES += api.js HTML_EXTRA_FILES += configuration.js + +# custom aliases +ALIASES += api_examples{3|}="@htmlonly@endhtmlonly" diff --git a/docs/api.js b/docs/api.js new file mode 100644 index 00000000000..5f887e94e93 --- /dev/null +++ b/docs/api.js @@ -0,0 +1,130 @@ +function generateExamples(endpoint, method, body = null) { + let curlBodyString = ''; + let psBodyString = ''; + + if (body) { + const curlJsonString = JSON.stringify(body).replace(/"/g, '\\"'); + curlBodyString = ` -d "${curlJsonString}"`; + psBodyString = `-Body (ConvertTo-Json ${JSON.stringify(body)})`; + } + + return { + cURL: `curl -u user:pass -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${curlBodyString}`, + Python: `import json +import requests +from requests.auth import HTTPBasicAuth + +requests.${method.trim().toLowerCase()}( + auth=HTTPBasicAuth('user', 'pass'), + url='https://localhost:47990${endpoint.trim()}', + verify=False,${body ? `\n json=${JSON.stringify(body)},` : ''} +).json()`, + JavaScript: `fetch('https://localhost:47990${endpoint.trim()}', { + method: '${method.trim()}', + headers: { + 'Authorization': 'Basic ' + btoa('user:pass'), + 'Content-Type': 'application/json', + }${body ? `,\n body: JSON.stringify(${JSON.stringify(body)}),` : ''} +}) +.then(response => response.json()) +.then(data => console.log(data));`, + PowerShell: `Invoke-RestMethod \` + -SkipCertificateCheck \` + -Uri 'https://localhost:47990${endpoint.trim()}' \` + -Method ${method.trim()} \` + -Headers @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes('user:pass'))} + ${psBodyString}` + }; +} + +function hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +function createTabs(examples) { + const languages = Object.keys(examples); + let tabs = '
'; + let content = '
'; + + languages.forEach((lang, index) => { + const hash = hashString(examples[lang]); + tabs += ``; + content += `
+
+
+ ${examples[lang].split('\n').map(line => `
${line}
`).join('')} +
+ + + + + + +
+
`; + }); + + tabs += '
'; + content += '
'; + + setTimeout(() => { + languages.forEach((lang, index) => { + const hash = hashString(examples[lang]); + const copyButton = document.getElementById(`copy-button-${lang}-${hash}`); + copyButton.addEventListener('click', copyContent); + }); + }, 0); + + return tabs + content; +} + +function copyContent() { + const content = this.previousElementSibling.cloneNode(true); + if (content instanceof Element) { + // filter out line number from file listings + content.querySelectorAll(".lineno, .ttc").forEach((node) => { + node.remove(); + }); + let textContent = Array.from(content.querySelectorAll('.line')) + .map(line => line.innerText) + .join('\n') + .trim(); // Join lines with newline characters and trim leading/trailing whitespace + navigator.clipboard.writeText(textContent); + this.classList.add("success"); + this.innerHTML = ``; + window.setTimeout(() => { + this.classList.remove("success"); + this.innerHTML = ``; + }, 980); + } else { + console.error('Failed to copy: content is not a DOM element'); + } +} + +function openTab(evt, lang) { + const tabcontent = document.getElementsByClassName("tabcontent"); + for (const content of tabcontent) { + content.style.display = "none"; + } + + const tablinks = document.getElementsByClassName("tab-button"); + for (const link of tablinks) { + link.className = link.className.replace(" active", ""); + } + + const selectedTabs = document.querySelectorAll(`#${lang}`); + for (const tab of selectedTabs) { + tab.style.display = "block"; + } + + const selectedButtons = document.querySelectorAll(`.tab-button[onclick*="${lang}"]`); + for (const button of selectedButtons) { + button.className += " active"; + } +} diff --git a/docs/api.md b/docs/api.md index 2c6e640989d..e93f500c061 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,6 +5,10 @@ Sunshine has a RESTful API which can be used to interact with the service. Unless otherwise specified, authentication is required for all API calls. You can authenticate using basic authentication with the admin username and password. +@htmlonly + +@endhtmlonly + ## GET /api/apps @copydoc confighttp::getApps() @@ -14,7 +18,7 @@ basic authentication with the admin username and password. ## POST /api/apps @copydoc confighttp::saveApp() -## DELETE /api/apps{index} +## DELETE /api/apps/{index} @copydoc confighttp::deleteApp() ## POST /api/covers/upload @@ -32,6 +36,9 @@ basic authentication with the admin username and password. ## POST /api/restart @copydoc confighttp::restart() +## POST /api/reset-display-device-persistence +@copydoc confighttp::resetDisplayDevicePersistence() + ## POST /api/password @copydoc confighttp::savePassword() @@ -47,7 +54,7 @@ basic authentication with the admin username and password. ## GET /api/clients/list @copydoc confighttp::listClients() -## GET /api/apps/close +## POST /api/apps/close @copydoc confighttp::closeApp()
diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 9b8570e0817..06ac1ebd594 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -142,18 +142,66 @@ namespace confighttp { return true; } + /** + * @brief Send a 404 Not Found response. + * @param response The original response object. + * @param request The original request object. + */ + void + not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { + pt::ptree tree; + tree.put("status_code", 404); + tree.put("error", "Not Found"); + + std::ostringstream data; + pt::write_json(data, tree); + + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + + response->write(SimpleWeb::StatusCode::client_error_not_found, data.str(), headers); + } + + /** + * @brief Send a 400 Bad Request response. + * @param response The original response object. + * @param request The original request object. + */ + void + bad_request_default(resp_https_t response, [[maybe_unused]] req_https_t request) { + pt::ptree tree; + tree.put("status_code", 400); + tree.put("error", "Invalid Request"); + + std::ostringstream data; + pt::write_json(data, tree); + + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + + response->write(SimpleWeb::StatusCode::client_error_bad_request, data.str(), headers); + } + + /** + * @brief Send a 400 Bad Request response. + * @param response The original response object. + * @param request The original request object. + * @param error_message The error message to include in the response. + */ void - not_found(resp_https_t response, req_https_t request) { + bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message) { pt::ptree tree; - tree.put("root..status_code", 404); + tree.put("status_code", 400); + tree.put("status", false); + tree.put("error", error_message); std::ostringstream data; + pt::write_json(data, tree); - pt::write_xml(data, tree); - response->write(data.str()); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); - *response << "HTTP/1.1 404 NOT FOUND\r\n" - << data.str(); + response->write(SimpleWeb::StatusCode::client_error_bad_request, data.str(), headers); } /** @@ -329,6 +377,8 @@ namespace confighttp { * @brief Get the list of available applications. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps| GET| null} */ void getApps(resp_https_t response, req_https_t request) { @@ -346,6 +396,8 @@ namespace confighttp { * @brief Get the logs from the log file. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/logs| GET| null} */ void getLogs(resp_https_t response, req_https_t request) { @@ -360,7 +412,7 @@ namespace confighttp { } /** - * @brief Save an application. If the application already exists, it will be updated, otherwise it will be added. + * @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application. * @param response The HTTP response object. * @param request The HTTP request object. * The body for the post request should be JSON serialized in the following format: @@ -385,9 +437,11 @@ namespace confighttp { * "detached": [ * "Detached command" * ], - * "image-path": "Full path to the application image. Must be a png file.", + * "image-path": "Full path to the application image. Must be a png file." * } * @endcode + * + * @api_examples{/api/apps| POST| {"name":"Hello, World!","index":-1}} */ void saveApp(resp_https_t response, req_https_t request) { @@ -467,9 +521,7 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "SaveApp: "sv << e.what(); - - outputTree.put("status", "false"); - outputTree.put("error", "Invalid Input JSON"); + bad_request(response, request, e.what()); return; } @@ -481,6 +533,8 @@ namespace confighttp { * @brief Delete an application. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps/9999| DELETE| null} */ void deleteApp(resp_https_t response, req_https_t request) { @@ -488,6 +542,8 @@ namespace confighttp { print_req(request); + int index; + pt::ptree outputTree; auto g = util::fail_guard([&]() { std::ostringstream data; @@ -499,35 +555,34 @@ namespace confighttp { try { pt::read_json(config::stream.file_apps, fileTree); auto &apps_node = fileTree.get_child("apps"s); - int index = stoi(request->path_match[1]); + index = stoi(request->path_match[1]); - if (index < 0) { - outputTree.put("status", "false"); - outputTree.put("error", "Invalid Index"); + if (std::clamp(index, 0, static_cast(apps_node.size()) - 1) != index) { + bad_request(response, request, std::format("index out of range, max index is {}", apps_node.size() - 1)); return; } - else { - // Unfortunately Boost PT does not allow to directly edit the array, copy should do the trick - pt::ptree newApps; - int i = 0; - for (const auto &[k, v] : apps_node) { - if (i++ != index) { - newApps.push_back(std::make_pair("", v)); - } + + // Unfortunately Boost PT does not allow to directly edit the array, copy should do the trick + pt::ptree newApps; + int i = 0; + for (const auto &[k, v] : apps_node) { + if (i++ != index) { + newApps.push_back(std::make_pair("", v)); } - fileTree.erase("apps"); - fileTree.push_back(std::make_pair("apps", newApps)); } + fileTree.erase("apps"); + fileTree.push_back(std::make_pair("apps", newApps)); + pt::write_json(config::stream.file_apps, fileTree); } catch (std::exception &e) { BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", "Invalid File JSON"); + bad_request(response, request, e.what()); return; } - outputTree.put("status", "true"); + outputTree.put("status", true); + outputTree.put("result", std::format("application {} deleted", index)); proc::refresh(config::stream.file_apps); } @@ -539,9 +594,11 @@ namespace confighttp { * @code{.json} * { * "key": "igdb_", - * "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/.png", + * "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/.png" * } * @endcode + * + * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} */ void uploadCover(resp_https_t response, req_https_t request) { @@ -568,8 +625,7 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "UploadCover: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", e.what()); + bad_request(response, request, e.what()); return; } @@ -607,6 +663,8 @@ namespace confighttp { * @brief Get the configuration settings. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/config| GET| null} */ void getConfig(resp_https_t response, req_https_t request) { @@ -637,6 +695,8 @@ namespace confighttp { * @brief Get the locale setting. This endpoint does not require authentication. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/configLocale| GET| null} */ void getLocale(resp_https_t response, req_https_t request) { @@ -668,6 +728,8 @@ namespace confighttp { * @endcode * * @attention{It is recommended to ONLY save the config settings that differ from the default behavior.} + * + * @api_examples{/api/config| POST| {"key":"value"}} */ void saveConfig(resp_https_t response, req_https_t request) { @@ -699,9 +761,7 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); - outputTree.put("status", "false"); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -709,6 +769,8 @@ namespace confighttp { * @brief Restart Sunshine. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/restart| POST| null} */ void restart(resp_https_t response, req_https_t request) { @@ -721,19 +783,11 @@ namespace confighttp { } /** - * @brief Update existing credentials. + * @brief Reset the display device persistence. * @param response The HTTP response object. * @param request The HTTP request object. - * The body for the post request should be JSON serialized in the following format: - * @code{.json} - * { - * "currentUsername": "Current Username", - * "currentPassword": "Current Password", - * "newUsername": "New Username", - * "newPassword": "New Password", - * "confirmNewPassword": "Confirm New Password" - * } - * @endcode + * + * @api_examples{/api/reset-display-device-persistence| POST| null} */ void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { @@ -751,12 +805,30 @@ namespace confighttp { outputTree.put("status", display_device::reset_persistence()); } + /** + * @brief Update existing credentials. + * @param response The HTTP response object. + * @param request The HTTP request object. + * The body for the post request should be JSON serialized in the following format: + * @code{.json} + * { + * "currentUsername": "Current Username", + * "currentPassword": "Current Password", + * "newUsername": "New Username", + * "newPassword": "New Password", + * "confirmNewPassword": "Confirm New Password" + * } + * @endcode + * + * @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}} + */ void savePassword(resp_https_t response, req_https_t request) { if (!config::sunshine.username.empty() && !authenticate(response, request)) return; print_req(request); + std::vector errors = {}; std::stringstream ss; std::stringstream configStream; ss << request->content.rdbuf(); @@ -779,15 +851,13 @@ namespace confighttp { auto confirmPassword = inputTree.count("confirmNewPassword") > 0 ? inputTree.get("confirmNewPassword") : ""; if (newUsername.length() == 0) newUsername = username; if (newUsername.length() == 0) { - outputTree.put("status", false); - outputTree.put("error", "Invalid Username"); + errors.emplace_back("Invalid Username"); } else { auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); if (config::sunshine.username.empty() || (boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password)) { if (newPassword.empty() || newPassword != confirmPassword) { - outputTree.put("status", false); - outputTree.put("error", "Password Mismatch"); + errors.emplace_back("Password Mismatch"); } else { http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword); @@ -796,16 +866,22 @@ namespace confighttp { } } else { - outputTree.put("status", false); - outputTree.put("error", "Invalid Current Credentials"); + errors.emplace_back("Invalid Current Credentials"); } } + + if (!errors.empty()) { + // join the errors array + std::string error = std::accumulate(errors.begin(), errors.end(), std::string(), + [](const std::string &a, const std::string &b) { + return a.empty() ? b : a + ", " + b; + }); + bad_request(response, request, error); + } } catch (std::exception &e) { BOOST_LOG(warning) << "SavePassword: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -820,6 +896,8 @@ namespace confighttp { * "name": "Friendly Client Name" * } * @endcode + * + * @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}} */ void savePin(resp_https_t response, req_https_t request) { @@ -847,9 +925,7 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "SavePin: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -857,6 +933,8 @@ namespace confighttp { * @brief Unpair all clients. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/clients/unpair-all| POST| null} */ void unpairAll(resp_https_t response, req_https_t request) { @@ -886,6 +964,8 @@ namespace confighttp { * "uuid": "" * } * @endcode + * + * @api_examples{/api/unpair| POST| {"uuid":"1234"}} */ void unpair(resp_https_t response, req_https_t request) { @@ -912,9 +992,7 @@ namespace confighttp { } catch (std::exception &e) { BOOST_LOG(warning) << "Unpair: "sv << e.what(); - outputTree.put("status", false); - outputTree.put("error", e.what()); - return; + bad_request(response, request, e.what()); } } @@ -922,6 +1000,8 @@ namespace confighttp { * @brief Get the list of paired clients. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/clients/list| GET| null} */ void listClients(resp_https_t response, req_https_t request) { @@ -949,6 +1029,8 @@ namespace confighttp { * @brief Close the currently running application. * @param response The HTTP response object. * @param request The HTTP request object. + * + * @api_examples{/api/apps/close| POST| null} */ void closeApp(resp_https_t response, req_https_t request) { @@ -976,7 +1058,11 @@ namespace confighttp { auto address_family = net::af_from_enum_string(config::sunshine.address_family); https_server_t server { config::nvhttp.cert, config::nvhttp.pkey }; + server.default_resource["DELETE"] = bad_request_default; server.default_resource["GET"] = not_found; + server.default_resource["PATCH"] = bad_request_default; + server.default_resource["POST"] = bad_request_default; + server.default_resource["PUT"] = bad_request_default; server.resource["^/$"]["GET"] = getIndexPage; server.resource["^/pin/?$"]["GET"] = getPinPage; server.resource["^/apps/?$"]["GET"] = getAppsPage;