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..95d7d8ed0c7
--- /dev/null
+++ b/docs/api.js
@@ -0,0 +1,122 @@
+function generateExamples(endpoint, method, body = null) {
+ return {
+ cURL: `curl -u user:pass -X ${method.trim()} -k https://localhost:47990${endpoint.trim()}${body ? ` -d "${JSON.stringify(body).replace(/"/g, '\\"')}"` : ''}`,
+ 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 ? `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'))}
+ ${body ? `-Body (ConvertTo-Json ${JSON.stringify(body)})` : ''}`
+ };
+}
+
+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 (let i = 0; i < tabcontent.length; i++) {
+ tabcontent[i].style.display = "none";
+ }
+
+ const tablinks = document.getElementsByClassName("tab-button");
+ for (let i = 0; i < tablinks.length; i++) {
+ tablinks[i].className = tablinks[i].className.replace(" active", "");
+ }
+
+ const selectedTabs = document.querySelectorAll(`#${lang}`);
+ for (let i = 0; i < selectedTabs.length; i++) {
+ selectedTabs[i].style.display = "block";
+ }
+
+ const selectedButtons = document.querySelectorAll(`.tab-button[onclick*="${lang}"]`);
+ for (let i = 0; i < selectedButtons.length; i++) {
+ selectedButtons[i].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..f63b84b46c1 100644
--- a/src/confighttp.cpp
+++ b/src/confighttp.cpp
@@ -142,18 +142,65 @@ 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, req_https_t request) {
+ not_found(resp_https_t response, [[maybe_unused]] req_https_t request) {
pt::ptree tree;
- tree.put("root..status_code", 404);
+ tree.put("status_code", 404);
+ tree.put("error", "Not Found");
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_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
+ bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message) {
+ pt::ptree tree;
+ tree.put("status_code", 400);
+ tree.put("error", error_message);
+
+ 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);
}
/**
@@ -329,6 +376,7 @@ 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 +394,7 @@ 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 +409,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 +434,10 @@ 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 +517,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 +529,7 @@ 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) {
@@ -501,33 +550,32 @@ namespace confighttp {
auto &apps_node = fileTree.get_child("apps"s);
int 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, "index out of range, max index is "s + std::to_string(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("result", "application " + request->path_match[1] + " deleted");
proc::refresh(config::stream.file_apps);
}
@@ -539,9 +587,10 @@ 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 +617,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 +655,7 @@ 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 +686,7 @@ 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 +718,7 @@ 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 +750,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 +758,7 @@ 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 +771,10 @@ 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 +792,29 @@ 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 +837,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 +852,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 +882,7 @@ 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 +910,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 +918,7 @@ 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 +948,7 @@ namespace confighttp {
* "uuid": ""
* }
* @endcode
+ * @api_examples{/api/unpair| POST| {"uuid":"1234"}}
*/
void
unpair(resp_https_t response, req_https_t request) {
@@ -912,9 +975,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 +983,7 @@ 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 +1011,7 @@ 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 +1039,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;