From 4268137f8bcfe8b5999ff50ca009d658acc94f7a Mon Sep 17 00:00:00 2001 From: Joly0 <13993216+Joly0@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:02:07 +0200 Subject: [PATCH] feat: add custom apps Lets users add *arr-like apps that are not built in (for example Chaptarr) with their own API version, optional OpenAPI URL and icon, and proxy them like the built-in starr apps. Queries use bound parameters, icons are checked by image content (PNG, JPEG, WebP), URLs must be http(s) and app names cannot shadow built-in apps or pages. --- root/app/www/public/ajax/customapps.php | 201 ++++++++++++++++++ root/app/www/public/ajax/starr.php | 6 +- root/app/www/public/api/index.php | 6 +- root/app/www/public/classes/Database.php | 1 + root/app/www/public/classes/Starr.php | 121 ++++++++++- .../www/public/classes/interfaces/Starr.php | 1 + .../classes/traits/Database/CustomApps.php | 87 ++++++++ root/app/www/public/functions/common.php | 8 +- root/app/www/public/includes/header.php | 20 +- root/app/www/public/index.php | 2 +- root/app/www/public/js/customapps.js | 90 ++++++++ root/app/www/public/js/settings.js | 9 + root/app/www/public/js/starr.js | 4 + .../www/public/migrations/009_custom_apps.php | 37 ++++ root/app/www/public/pages/settings.php | 42 +++- root/app/www/public/pages/starr.php | 7 +- 16 files changed, 621 insertions(+), 21 deletions(-) create mode 100644 root/app/www/public/ajax/customapps.php create mode 100644 root/app/www/public/classes/traits/Database/CustomApps.php create mode 100644 root/app/www/public/js/customapps.js create mode 100644 root/app/www/public/migrations/009_custom_apps.php diff --git a/root/app/www/public/ajax/customapps.php b/root/app/www/public/ajax/customapps.php new file mode 100644 index 0000000..056a052 --- /dev/null +++ b/root/app/www/public/ajax/customapps.php @@ -0,0 +1,201 @@ + 'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_WEBP => 'webp']; +const CUSTOM_APP_ICON_MAX_SIZE = 262144; + +if ($_POST['m'] == 'getCustomApps') { + $customApps = $proxyDb->getCustomApps(); + + ?> + + + + + + + + + + + + + + + + + + + + + + + +
IconNameLabelAPI VersionOpenAPI URLActions
None (manual endpoints only)' ?> + + +
No custom apps yet. Add one below.
+ getCustomApp($_POST['id']) : []; + + ?> + + + + + + + + + + + + + + + + + + + + + +
+ Name
+ Internal identifier: lowercase letters and digits, starting with a letter, like "myapp". Cannot be changed later. +
>
+ Display Label
+ Shown in the navigation bar +
+ API Version
+ The API version prefix (e.g. v1, v3) +
+ OpenAPI URL (optional)
+ http(s) URL to an openapi.json for endpoint discovery. Leave blank to manage endpoints manually. +
+ Icon (optional)
+ PNG, JPEG or WebP image for the nav bar. Max 256KB. + +

Current: + +
+ +
+ basename($page, '.php'), glob(ABSOLUTE_PATH . 'pages/*.php')), ['api', 'feed', 'backup']); + + if (!$id && !preg_match('/^[a-z][a-z0-9]{1,31}$/', $name)) { + $error = 'Name must be 2 to 32 lowercase letters or digits, starting with a letter'; + } elseif (!$id && in_array($name, $reservedNames)) { + $error = 'The name "' . $name . '" is reserved, pick another one'; + } elseif ($label === '' || mb_strlen($label) > 64) { + $error = 'Label is required, max 64 characters'; + } elseif (!preg_match('/^v\d+$/', $apiVersion)) { + $error = 'API version must look like v1 or v3'; + } elseif ($openapiUrl && !$starr->isHttpUrl($openapiUrl)) { + $error = 'OpenAPI URL must be an http or https URL'; + } else { + $fields = ['label' => $label, 'api_version' => $apiVersion, 'openapi_url' => $openapiUrl]; + + if ($id) { + $error = $proxyDb->updateCustomApp($id, $fields); + } else { + $error = $proxyDb->addCustomApp($fields + ['name' => $name]); + $id = $proxyDb->insertId(); + + if (!$error) { + $proxyDb->setCustomAppNavSetting($name, true); + } + } + } + + echo json_encode($error ? ['error' => $error] : ['id' => $id]); +} + +if ($_POST['m'] == 'uploadCustomAppIcon') { + $customApp = $proxyDb->getCustomApp($_POST['id']); + $iconType = $_FILES['icon']['tmp_name'] ? (getimagesize($_FILES['icon']['tmp_name'])[2] ?? 0) : 0; + + if (!$customApp) { + $error = 'App not found'; + } elseif ($_FILES['icon']['error'] != UPLOAD_ERR_OK) { + $error = 'No file uploaded or upload error'; + } elseif ($_FILES['icon']['size'] > CUSTOM_APP_ICON_MAX_SIZE) { + $error = 'File too large, max 256KB'; + } elseif (!isset(CUSTOM_APP_ICON_TYPES[$iconType])) { //-- CHECK THE ACTUAL IMAGE CONTENT, NOT THE CLIENT FILENAME + $error = 'Icon must be a PNG, JPEG or WebP image'; + } else { + $iconDir = ABSOLUTE_PATH . 'images/custom/'; + $filename = $customApp['name'] . '.' . CUSTOM_APP_ICON_TYPES[$iconType]; + + if (!move_uploaded_file($_FILES['icon']['tmp_name'], $iconDir . $filename)) { + $error = 'Could not store the icon'; + } else { + if ($customApp['icon'] && $customApp['icon'] != $filename && file_exists($iconDir . $customApp['icon'])) { + unlink($iconDir . $customApp['icon']); + } + + $error = $proxyDb->updateCustomApp($customApp['id'], ['icon' => $filename]); + } + } + + echo json_encode($error ? ['error' => $error] : ['icon' => $filename]); +} + +if ($_POST['m'] == 'deleteCustomApp') { + $customApp = $proxyDb->getCustomApp($_POST['id']); + + if (!$customApp) { + $error = 'App not found'; + } elseif (array_filter($starrsTable ?: [], fn($starrInstance) => $starrInstance['starr'] == $customApp['id'] + StarrApps::CUSTOM_ID_OFFSET)) { + $error = 'Cannot delete: there are starr instances using this custom app. Remove them first.'; + } elseif (!$error = $proxyDb->deleteCustomApp($customApp['id'])) { + $proxyDb->deleteCustomAppNavSetting($customApp['name']); + + if ($customApp['icon'] && file_exists(ABSOLUTE_PATH . 'images/custom/' . $customApp['icon'])) { + unlink(ABSOLUTE_PATH . 'images/custom/' . $customApp['icon']); + } + } + + echo json_encode($error ? ['error' => $error] : []); +} diff --git a/root/app/www/public/ajax/starr.php b/root/app/www/public/ajax/starr.php index 2e46dc2..a8f7d11 100644 --- a/root/app/www/public/ajax/starr.php +++ b/root/app/www/public/ajax/starr.php @@ -64,7 +64,7 @@ if (!is_array($test['response']) && str_contains($test['response'], 'doctype')) { $error = 'The starr app returned HTML, this typically happens with a bad base URL.'; } else { - $name = $test['response']['instanceName']; + $name = $test['response']['instanceName'] ?: ucfirst($app); } } @@ -187,7 +187,7 @@ ?> -
+
- id="endpoint-counter-" data-endpoint="" data-method="" type="checkbox" class="form-check-input endpoint-"> + id="endpoint-counter-" data-endpoint="" data-method="" type="checkbox" class="form-check-input endpoint-">
getAllAppsList()); + } elseif (!in_array($request['starr'], $starr->getAllAppsList())) { + $requestError = 'starr field is not valid, should be one of: ' . implode(', ', $starr->getAllAppsList()); } elseif (!$request['url']) { $requestError = 'url field is required, should be the local url to the starr app'; } elseif (!$request['apikey']) { diff --git a/root/app/www/public/classes/Database.php b/root/app/www/public/classes/Database.php index d6b2830..d9b28c5 100644 --- a/root/app/www/public/classes/Database.php +++ b/root/app/www/public/classes/Database.php @@ -19,6 +19,7 @@ class Database use Settings; use Starrs; use Usage; + use CustomApps; public $db; public $dbName; diff --git a/root/app/www/public/classes/Starr.php b/root/app/www/public/classes/Starr.php index 2174556..35ede09 100644 --- a/root/app/www/public/classes/Starr.php +++ b/root/app/www/public/classes/Starr.php @@ -16,12 +16,14 @@ class Starr use EndpointOverrides; public $cache; + public $customApps; public function __construct() { global $cache; - $this->cache = $cache ?: new Cache(); + $this->cache = $cache ?: new Cache(); + $this->customApps = null; } public function __tostring() @@ -29,6 +31,60 @@ public function __tostring() return 'Class loaded: Starr'; } + public function loadCustomApps() + { + global $proxyDb; + + if ($this->customApps === null && $proxyDb) { + $this->customApps = $proxyDb->getCustomApps(); + } + + return $this->customApps ?: []; + } + + public function isHttpUrl($url) + { + return in_array(strtolower(parse_url($url, PHP_URL_SCHEME) ?: ''), ['http', 'https']) && parse_url($url, PHP_URL_HOST); + } + + public function getAllAppsList() + { + $list = StarrApps::LIST; + $customApps = $this->loadCustomApps(); + + foreach ($customApps as $customApp) { + $list[] = strtolower($customApp['name']); + } + + return $list; + } + + public function isCustomApp($name) + { + $customApps = $this->loadCustomApps(); + + foreach ($customApps as $customApp) { + if (strtolower($customApp['name']) == strtolower($name)) { + return $customApp; + } + } + + return false; + } + + public function getCustomAppFromId($id) + { + $customApps = $this->loadCustomApps(); + + foreach ($customApps as $customApp) { + if ($customApp['id'] + StarrApps::CUSTOM_ID_OFFSET == $id) { + return $customApp; + } + } + + return false; + } + public function getStarrInterfaceIdFromName($name) { switch ($name) { @@ -45,6 +101,13 @@ public function getStarrInterfaceIdFromName($name) case 'whisparr': return StarrApps::WHISPARR_ID; } + + $customApp = $this->isCustomApp($name); + if ($customApp) { + return $customApp['id'] + StarrApps::CUSTOM_ID_OFFSET; + } + + return null; } public function getStarrInterfaceNameFromId($id) @@ -63,6 +126,42 @@ public function getStarrInterfaceNameFromId($id) case StarrApps::WHISPARR_ID: return strtolower(StarrApps::WHISPARR_LABEL); } + + $customApp = $this->getCustomAppFromId($id); + if ($customApp) { + return strtolower($customApp['name']); + } + + return null; + } + + public function getStarrInterfaceLabelFromId($id) + { + $name = $this->getStarrInterfaceNameFromId($id); + if (!$name) { + return null; + } + + $customApp = $this->isCustomApp($name); + if ($customApp) { + return $customApp['label']; + } + + return ucfirst($name); + } + + public function getAppIcon($name) + { + $customApp = $this->isCustomApp($name); + if ($customApp && $customApp['icon']) { + return 'images/custom/' . $customApp['icon']; + } + + if (in_array($name, StarrApps::LIST)) { + return 'images/logos/' . $name . '.png'; + } + + return ''; } public function apiVersion($app) @@ -77,11 +176,19 @@ public function apiVersion($app) case 'whisparr': return 'v3'; } + + $customApp = $this->isCustomApp($app); + if ($customApp) { + return $customApp['api_version']; + } + + return 'v1'; } public function testConnection($app, $url, $apikey) { - $url = $url . '/api/' . $this->apiVersion($app) . '/config/host'; + //-- CUSTOM APPS ARE NOT GUARANTEED TO HAVE config/host, system/status IS COMMON TO EVERY *ARR API + $url = $url . '/api/' . $this->apiVersion($app) . ($this->isCustomApp($app) ? '/system/status' : '/config/host'); $headers = ['x-api-key:' . $apikey]; $curl = curl($url, $headers); @@ -121,7 +228,13 @@ public function getEndpoints($app) $openapi = 'https://raw.githubusercontent.com/Whisparr/Whisparr/eros/src/Whisparr.Api.V3/openapi.json'; break; default: - return $endpoints; + $customApp = $this->isCustomApp($app); + if (!$customApp || !$this->isHttpUrl($customApp['openapi_url'])) { + return $endpoints; + } + + $openapi = $customApp['openapi_url']; + break; } $openapi = curl($openapi); @@ -289,7 +402,7 @@ public function requestMatchesEndpointTemplate($requestEndpoint, $templateEndpoi public function findWildcardEndpoint($starrApp, $endpoint) { - foreach (StarrApps::LIST as $listStarr) { + foreach ($this->getAllAppsList() as $listStarr) { if (strtolower($listStarr) != strtolower($starrApp)) { continue; } diff --git a/root/app/www/public/classes/interfaces/Starr.php b/root/app/www/public/classes/interfaces/Starr.php index 595f162..bb9fd5a 100644 --- a/root/app/www/public/classes/interfaces/Starr.php +++ b/root/app/www/public/classes/interfaces/Starr.php @@ -22,4 +22,5 @@ interface StarrApps public const SONARR_LABEL = 'Sonarr'; public const WHISPARR_ID = 6; public const WHISPARR_LABEL = 'Whisparr'; + public const CUSTOM_ID_OFFSET = 100; } diff --git a/root/app/www/public/classes/traits/Database/CustomApps.php b/root/app/www/public/classes/traits/Database/CustomApps.php new file mode 100644 index 0000000..9da3cf3 --- /dev/null +++ b/root/app/www/public/classes/traits/Database/CustomApps.php @@ -0,0 +1,87 @@ +db->prepare($q); + if (!$stmt) { + return false; + } + + foreach ($params as $param => $value) { + $stmt->bindValue(':' . $param, $value, is_int($value) ? SQLITE3_INTEGER : SQLITE3_TEXT); + } + + return $stmt->execute(); + } + + public function getCustomApps() + { + $apps = []; + $r = $this->customAppsQuery("SELECT * FROM custom_apps ORDER BY label ASC"); + + while ($r && $row = $r->fetchArray(SQLITE3_ASSOC)) { + $apps[] = $row; + } + + return $apps; + } + + public function getCustomApp($id) + { + $r = $this->customAppsQuery("SELECT * FROM custom_apps WHERE id = :id", ['id' => intval($id)]); + + return ($r ? $r->fetchArray(SQLITE3_ASSOC) : []) ?: []; + } + + public function addCustomApp($fields = []) + { + $r = $this->customAppsQuery("INSERT INTO custom_apps (name, label, api_version, openapi_url) VALUES (:name, :label, :api_version, :openapi_url)", [ + 'name' => $fields['name'], + 'label' => $fields['label'], + 'api_version' => $fields['api_version'], + 'openapi_url' => $fields['openapi_url'] + ]); + + return $r ? '' : 'addCustomApp() :: ' . $this->db->lastErrorMsg(); + } + + public function updateCustomApp($id, $fields = []) + { + $columns = array_intersect(array_keys($fields), ['label', 'api_version', 'openapi_url', 'icon']); + if (!$columns) { + return; + } + + $set = implode(', ', array_map(fn($column) => $column . ' = :' . $column, $columns)); + $r = $this->customAppsQuery("UPDATE custom_apps SET " . $set . " WHERE id = :id", array_intersect_key($fields, array_flip($columns)) + ['id' => intval($id)]); + + return $r ? '' : 'updateCustomApp() :: ' . $this->db->lastErrorMsg(); + } + + public function deleteCustomApp($id) + { + $r = $this->customAppsQuery("DELETE FROM custom_apps WHERE id = :id", ['id' => intval($id)]); + + return $r ? '' : 'deleteCustomApp() :: ' . $this->db->lastErrorMsg(); + } + + public function setCustomAppNavSetting($name, $value) + { + $this->customAppsQuery("INSERT OR REPLACE INTO " . SETTINGS_TABLE . " (name, value) VALUES (:name, :value)", ['name' => 'uiHeaderCustom_' . $name, 'value' => $value ? '1' : '0']); + } + + public function deleteCustomAppNavSetting($name) + { + $this->customAppsQuery("DELETE FROM " . SETTINGS_TABLE . " WHERE name = :name", ['name' => 'uiHeaderCustom_' . $name]); + } +} diff --git a/root/app/www/public/functions/common.php b/root/app/www/public/functions/common.php index de31382..4074c45 100644 --- a/root/app/www/public/functions/common.php +++ b/root/app/www/public/functions/common.php @@ -32,16 +32,20 @@ function loadClassExtras($class) function automation() { + global $starr; + $shell = $shell ?? new Shell(); //-- CREATE DIRECTORIES - $createFolders = [LOGS_PATH, LOGS_PATH . 'system/', LOGS_PATH . 'notifications/', BACKUP_PATH, APP_USER_TEMPLATES_PATH, DATABASE_PATH, MIGRATIONS_PATH]; + $createFolders = [LOGS_PATH, LOGS_PATH . 'system/', LOGS_PATH . 'notifications/', BACKUP_PATH, APP_USER_TEMPLATES_PATH, DATABASE_PATH, MIGRATIONS_PATH, ABSOLUTE_PATH . 'images/custom/']; foreach ($createFolders as $createFolder) { if (!is_dir($createFolder)) { createDirectoryTree($createFolder); } } - foreach (StarrApps::LIST as $starrApp) { + //-- INCLUDES CUSTOM APPS WHEN THE STARR CLASS IS LOADED + $allApps = $starr ? $starr->getAllAppsList() : StarrApps::LIST; + foreach ($allApps as $starrApp) { if (!is_dir(APP_USER_TEMPLATES_PATH . $starrApp)) { createDirectoryTree(APP_USER_TEMPLATES_PATH . $starrApp); } diff --git a/root/app/www/public/includes/header.php b/root/app/www/public/includes/header.php index 5f091a9..4a83f6b 100644 --- a/root/app/www/public/includes/header.php +++ b/root/app/www/public/includes/header.php @@ -74,15 +74,29 @@ } ?> - loadCustomApps() as $customApp) { + $customName = strtolower($customApp['name']); + $customId = $starr->getStarrInterfaceIdFromName($customName); + $active = $app == $customName; + $starrAppCount = count(array_filter($starrsTable ?: [], fn($starrDbApp) => $starrDbApp['starr'] == $customId)); + $icon = $customApp['icon'] ? 'images/custom/' . $customApp['icon'] : 'images/logo-16.png'; + + ?> + +