From f74493128231bd9d183b6ebd16ea4d24b340c553 Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Wed, 9 Sep 2026 12:12:57 -0400 Subject: [PATCH 1/3] Add a release-notes link to plugin cards and the detail modal Plugin cards (and the detail modal's Home/Source/Bug link list) had no way to see what changed in an update without leaving FPP for the plugin's own repo. Adds a release-notes link, auto-derived from the plugin's GitHub srcURL (reusing GitHubRepoOf(), the same owner/repo derivation the existing GitHub-stats badge uses) as https://github.com///releases -- no new pluginInfo.json field, works immediately for every existing GitHub-hosted plugin. Rendered inline in the card's bottom action row via the same ms-auto pattern the GitHub-stats badge already uses (so the two cluster together at the trailing edge whether or not stats data is present), and shown at every UI level -- unlike the stats badge, this needs no API call, so there's no reason to gate it behind Advanced/Developer. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NjuzBZvDeLNNaGX7j9mC8p --- www/css/fpp.css | 9 +++++++++ www/plugins.php | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/www/css/fpp.css b/www/css/fpp.css index 37f3ffe28..370f600dd 100644 --- a/www/css/fpp.css +++ b/www/css/fpp.css @@ -4618,6 +4618,15 @@ pre.testData, -webkit-box-orient: vertical; overflow: hidden; } +/* Release-notes icon link in the card action row / detail modal footer */ +.pluginReleaseNotesLink { + color: var(--fpp-text-muted); + text-decoration: none; + font-size: 1rem; +} +.pluginReleaseNotesLink:hover { + color: var(--bs-link-color); +} /* Popular Plugins: single horizontally-scrollable row (functional scroll boundary + fixed item width so flex children don't collapse; no Bootstrap utility covers a fixed rem width). Scroll is contained here so the page diff --git a/www/plugins.php b/www/plugins.php index ec6d22bc0..fa90ef5b8 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -1357,6 +1357,28 @@ function GitHubStatsRowHtml(repo) { return '
' + badge + '
'; } + // Public releases page for a GitHub-hosted plugin ('' when not GitHub-hosted). + // Reuses GitHubRepoOf() -- same owner/repo derivation the stats badge uses -- + // but needs no fetch or uiLevel gate: it's a plain static link, not an API call. + function PluginReleaseNotesUrl(data) { + var repo = GitHubRepoOf(data); + return repo ? 'https://github.com/' + repo + '/releases' : ''; + } + + // Release-notes link, wrapped for inline placement at the right end of the + // action row alongside (or in place of) the GitHub stats badge -- same + // ms-auto pattern as GitHubStatsRowHtml, so the two cluster together at the + // trailing edge whether or not the stats badge is also present. Unlike the + // stats badge this is shown at every UI level: it's how the community finds + // out what changed without hunting down the plugin's repo. + function ReleaseNotesRowHtml(data) { + var url = PluginReleaseNotesUrl(data); + if (!url) return ''; + return '' + + ''; + } + // Stamp the counts onto already-rendered cards (called once counts // arrive; cards rendered after that get the corner inline in LoadPlugin). // Only touches cards whose repo we have counts for -- cards without data @@ -1704,6 +1726,8 @@ function ShowPluginDetail(repo) { }; if (IsSafeHttpUrl(data.srcURL) && !sameLink(data.srcURL, data.homeURL)) body += ' View Source'; if (IsSafeHttpUrl(data.bugURL)) body += ' Report a Bug'; + var releaseNotesUrl = PluginReleaseNotesUrl(data); + if (releaseNotesUrl) body += ' Release Notes'; body += ''; var buttons = {}; @@ -1917,7 +1941,7 @@ function LoadPlugin(data, insert = false) { // closest() and stops there, same effect as the old inline // event.stopPropagation() without needing it on every button. html += '
' + - actions + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + '
'; + actions + ReleaseNotesRowHtml(data) + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + ''; html += ''; if (installed) { From 0c19b184b48905eb0880abbbdd6de87908996468 Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Wed, 9 Sep 2026 12:30:52 -0400 Subject: [PATCH 2/3] Show release notes in-app instead of just linking out Replaces the plain external link with an in-app modal, mirroring how about.php already shows FPP's own OS release notes: a new GET /api/plugin/releaseNotes endpoint proxies GitHub's releases API server-side (same TTL-cache convention as the existing GitHub-stats endpoint, and keeps api.github.com out of the CSP), and the frontend renders the release body with the same narrow, dependency-free markdown-to-HTML converter about.php uses -- HTML-escape first, then only ever add back a fixed whitelist of tags for a fixed whitelist of markdown constructs, so nothing in a plugin author's release text can inject arbitrary markup. The card icon and the detail modal's "Release Notes" link now both dispatch through the existing data-plugin-action click handler (action: "releaseNotes") instead of being a plain , since opening a modal needs to run JS, not navigate. Guards against stacking a second Bootstrap modal when triggered from inside the already-open detail modal. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NjuzBZvDeLNNaGX7j9mC8p --- www/api/controllers/plugin.php | 91 ++++++++++++++++++++++++ www/api/index.php | 1 + www/api/openapi.json | 28 ++++++++ www/plugins.php | 125 ++++++++++++++++++++++++++++----- 4 files changed, 226 insertions(+), 19 deletions(-) diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 79c0a0770..72a5af117 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -2275,6 +2275,97 @@ function GetPluginGitHubStats() return json(array('repos' => $result, 'source' => $source)); } +define('PLUGIN_RELEASE_NOTES_CACHE_TTL', 6 * 60 * 60); // 6h, same horizon as PLUGIN_GITHUB_STATS_TTL + +function PluginReleaseNotesCacheFile() +{ + global $settings; + $base = isset($settings['mediaDirectory']) ? $settings['mediaDirectory'] : '/home/fpp/media'; + return $base . '/tmp/pluginReleaseNotes.cache.json'; +} + +/** + * Fetch a GitHub-hosted plugin's latest release, proxied server-side. + * + * Same idea as GitOSReleaseNotes() (FPP's own release-notes endpoint, + * hardcoded to FalconChristmas/fpp) but for an arbitrary plugin repo, and + * TTL-cached the same way PluginGitHubStatsCacheFile() is: GitHub's + * unauthenticated API rate limit is shared across every box calling in from + * behind the same NAT, and this keeps api.github.com out of the CSP the way + * every other third-party call in this file already does. + * + * @route GET /api/plugin/releaseNotes + * @response 200 The repo's latest release (trimmed to the fields the UI uses) + * ```json + * {"name": "v1.2.0", "tag_name": "v1.2.0", "published_at": "2026-01-01T00:00:00Z", "body": "...", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0"} + * ``` + */ +function GetPluginReleaseNotes() +{ + $repo = isset($_GET['repo']) ? trim($_GET['repo']) : ''; + if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repo)) { + http_response_code(400); + return json(array('status' => 'ERROR', 'message' => 'Invalid repo')); + } + + $cacheFile = PluginReleaseNotesCacheFile(); + $cache = array(); + if (file_exists($cacheFile)) { + $tmp = json_decode(@file_get_contents($cacheFile), true); + if (is_array($tmp)) $cache = $tmp; + } + + $key = strtolower($repo); + $now = time(); + if (isset($cache[$key]['ts']) && ($now - (int)$cache[$key]['ts']) < PLUGIN_RELEASE_NOTES_CACHE_TTL) { + if (!empty($cache[$key]['notFound'])) { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'No releases found')); + } + return json($cache[$key]['data']); + } + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_URL, "https://api.github.com/repos/" . $repo . "/releases/latest"); + curl_setopt($curl, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:17.0) Gecko/20100101 Firefox/17.0"); + curl_setopt($curl, CURLOPT_FAILONERROR, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT_MS, 4000); + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($response === false || $httpCode >= 400) { + $cache[$key] = array('notFound' => true, 'ts' => $now); + @file_put_contents($cacheFile, json_encode($cache)); + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'No releases found')); + } + + $data = json_decode($response, true); + if ($data === null) { + http_response_code(502); + return json(array('status' => 'ERROR', 'message' => 'Invalid response from GitHub')); + } + + // Only keep the fields the UI actually uses -- GitHub's release object + // carries a lot more (assets, author, reactions, ...) that would just + // bloat the cache file for no benefit here. + $trimmed = array( + 'name' => isset($data['name']) ? $data['name'] : '', + 'tag_name' => isset($data['tag_name']) ? $data['tag_name'] : '', + 'published_at' => isset($data['published_at']) ? $data['published_at'] : '', + 'body' => isset($data['body']) ? $data['body'] : '', + 'html_url' => isset($data['html_url']) ? $data['html_url'] : '', + ); + + $cache[$key] = array('data' => $trimmed, 'ts' => $now); + @file_put_contents($cacheFile, json_encode($cache)); + + return json($trimmed); +} + /** * Fetch a pluginInfo.json on the browser's behalf * diff --git a/www/api/index.php b/www/api/index.php index a01e23854..5cadc40ef 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -224,6 +224,7 @@ dispatch_get('/plugin/popularity', 'GetPluginPopularity'); // keep above /plugin/:RepoName dispatch_get('/plugin/githubStats', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName dispatch_get('/plugin/source', 'GetPluginSource'); // keep above /plugin/:RepoName +dispatch_get('/plugin/releaseNotes', 'GetPluginReleaseNotes'); // keep above /plugin/:RepoName dispatch_get('/plugin/:RepoName', 'GetPluginInfo'); dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon'); dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl'); diff --git a/www/api/openapi.json b/www/api/openapi.json index ed0a21584..c4665c6d7 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -6763,6 +6763,34 @@ } } }, + "/api/plugin/releaseNotes": { + "get": { + "tags": [ + "plugin" + ], + "summary": "Fetch a GitHub-hosted plugin's latest release, proxied server-side.", + "description": "Same idea as GitOSReleaseNotes() (FPP's own release-notes endpoint, hardcoded to FalconChristmas/fpp) but for an arbitrary plugin repo, and TTL-cached the same way PluginGitHubStatsCacheFile() is: GitHub's unauthenticated API rate limit is shared across every box calling in from behind the same NAT, and this keeps api.github.com out of the CSP the way every other third-party call in this file already does.", + "responses": { + "200": { + "description": "The repo's latest release (trimmed to the fields the UI uses)", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "v1.2.0", + "tag_name": "v1.2.0", + "published_at": "2026-01-01T00:00:00Z", + "body": "...", + "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0" + } + } + } + } + } + } + }, "/api/plugin/source": { "get": { "tags": [ diff --git a/www/plugins.php b/www/plugins.php index fa90ef5b8..b52abe268 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -1357,28 +1357,115 @@ function GitHubStatsRowHtml(repo) { return '
' + badge + '
'; } - // Public releases page for a GitHub-hosted plugin ('' when not GitHub-hosted). - // Reuses GitHubRepoOf() -- same owner/repo derivation the stats badge uses -- - // but needs no fetch or uiLevel gate: it's a plain static link, not an API call. - function PluginReleaseNotesUrl(data) { - var repo = GitHubRepoOf(data); - return repo ? 'https://github.com/' + repo + '/releases' : ''; - } - - // Release-notes link, wrapped for inline placement at the right end of the - // action row alongside (or in place of) the GitHub stats badge -- same + // Release-notes trigger, wrapped for inline placement at the right end of + // the action row alongside (or in place of) the GitHub stats badge -- same // ms-auto pattern as GitHubStatsRowHtml, so the two cluster together at the - // trailing edge whether or not the stats badge is also present. Unlike the - // stats badge this is shown at every UI level: it's how the community finds - // out what changed without hunting down the plugin's repo. + // trailing edge whether or not the stats badge is also present. '' when the + // plugin isn't GitHub-hosted (GitHubRepoOf() can't derive an owner/repo). + // Shown at every UI level: unlike the stats badge this needs no upfront API + // call (ShowPluginReleaseNotes fetches on click), so there's no rate-limit + // reason to gate it behind Advanced/Developer. + // javascript:void(0) href, not a real link: this opens an in-app modal (see + // ShowPluginReleaseNotes) via the card's existing data-plugin-action + // delegated click handler, same as the Install/Update/Uninstall buttons. function ReleaseNotesRowHtml(data) { - var url = PluginReleaseNotesUrl(data); - if (!url) return ''; - return '
' + + if (!GitHubRepoOf(data)) return ''; + return '' + ''; } + // Narrow, dependency-free markdown-to-HTML used for GitHub release bodies -- + // mirrors the converter about.php uses for FPP's own OS release notes + // (rather than pulling in a general markdown renderer for text a plugin + // author supplies). HTML-escapes FIRST, then only ever adds back a fixed + // whitelist of tags for a fixed whitelist of markdown constructs, so nothing + // in the source text can inject arbitrary markup. + function PluginMarkdownToSafeHtml(md) { + var body = md + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/^### (.+)$/gm, '
$1
') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/```(.+?)```/gs, '
$1
') + .replace(/`(.+?)`/g, '$1') + .replace(/\[(.+?)\]\((.+?)\)/g, '$1') + .replace(/\n\n/g, '

') + .replace(/\n/g, '
'); + + body = body.replace(/(
)?- (.+?)(
|<\/p>)/g, function (match, br1, content) { + return '

  • ' + content + '
  • '; + }); + body = body.replace(/(
  • .*?<\/li>)+/g, function (match) { + return '
      ' + match + '
    '; + }); + + return '

    ' + body + '

    '; + } + + // Fetch and show a plugin's latest GitHub release in a modal -- same shape + // as about.php's OS-release-notes flow (loading spinner, then patch + // .modal-body once the proxied api/plugin/releaseNotes call resolves). + function ShowPluginReleaseNotes(repo) { + var i = FindPluginInfo(repo); + if (i < 0) return; + var data = pluginInfos[i]; + var ghRepo = GitHubRepoOf(data); + if (!ghRepo) return; + + // This link also lives inside the plugin detail modal, so a click there + // would otherwise stack a second Bootstrap modal on top of it. Close it + // first -- but only when it's actually open: reachable from the card + // grid directly, where pluginDetailDialog may never have existed this + // session, and CloseModalDialog()'s unconditional .hide() throws on a + // modal with no Bootstrap instance yet. + if ($('#pluginDetailDialog').hasClass('show')) { + CloseModalDialog('pluginDetailDialog'); + } + + DoModalDialog({ + id: 'pluginReleaseNotesModal', + title: 'Release Notes: ' + EscapeHtml(data.name), + body: '
    Loading...
    ', + class: 'modal-lg modal-dialog-scrollable', + keyboard: true, + backdrop: true + }); + + $.ajax({ + url: 'api/plugin/releaseNotes?repo=' + encodeURIComponent(ghRepo), + dataType: 'json', + success: function (release) { + var html = ''; + if (release.published_at) { + var date = new Date(release.published_at); + html += '

    Published: ' + date.toLocaleDateString() + '

    '; + } + html += release.body + ? '
    ' + PluginMarkdownToSafeHtml(release.body) + '
    ' + : '

    No release notes text was provided for this release.

    '; + if (IsSafeHttpUrl(release.html_url)) { + html += ''; + } + $('#pluginReleaseNotesModal .modal-body').html(html); + }, + error: function () { + var html = '
    ' + + '
    No GitHub releases found for this plugin.
    ' + + '
    '; + $('#pluginReleaseNotesModal .modal-body').html(html); + } + }); + } + // Stamp the counts onto already-rendered cards (called once counts // arrive; cards rendered after that get the corner inline in LoadPlugin). // Only touches cards whose repo we have counts for -- cards without data @@ -1726,8 +1813,7 @@ function ShowPluginDetail(repo) { }; if (IsSafeHttpUrl(data.srcURL) && !sameLink(data.srcURL, data.homeURL)) body += ' View Source'; if (IsSafeHttpUrl(data.bugURL)) body += ' Report a Bug'; - var releaseNotesUrl = PluginReleaseNotesUrl(data); - if (releaseNotesUrl) body += ' Release Notes'; + if (GitHubRepoOf(data)) body += ' Release Notes'; body += ''; var buttons = {}; @@ -2373,6 +2459,7 @@ function FilterPlugins() { else if (action === 'uninstall') ShowUninstallPluginPopup(repo); else if (action === 'reinstall') ShowReinstallPluginPopup(repo); else if (action === 'install') ConfirmAndInstall(repo, el.dataset.branch, el.dataset.sha); + else if (action === 'releaseNotes') ShowPluginReleaseNotes(repo); // 'none' (blank space in a card's actions row): absorb the click, // do nothing -- same effect the old event.stopPropagation() had. }); From 933fb9eed03cbf9769497d35531e6e2190641171 Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Tue, 15 Sep 2026 14:55:24 -0400 Subject: [PATCH 3/3] Gate release notes on an explicit releaseNotesStyle, not "is GitHub-hosted" Implements dkulp's proposed approach from PR review: almost no plugin has a tagged GitHub Release today, so showing the release-notes icon on every GitHub-hosted plugin (the original design) meant showing a dead end for nearly everyone who clicked it. Gate on a new pluginInfo.json field instead: "releaseNotesStyle": "none" | "gitRelease" | "gitHistory" | "script" - gitRelease: unchanged behavior -- latest GitHub Release, proxied and TTL-cached server-side. - gitHistory (new): the commits between the installed clone's HEAD and origin/, read directly from the plugin's own repo -- no GitHub API call, works for any git-hosted plugin whether or not it tags releases. Reuses PluginCurrentBranch() and the same delimited git-log format changelog.php already uses for FPP's own commit history. - script (new): runs the plugin's own scripts/fpp_releasenotes.sh and shows its stdout as plain (HTML-escaped) text, for plugins whose update-worthy changes aren't in the git log at all -- the same fpp_update_check.sh / fpp_upgrade.sh escape hatch PluginHasUpdates() already accommodates. The endpoint moved from `GET /api/plugin/releaseNotes?repo=owner/name` (client-supplied, gitRelease-only) to `GET /api/plugin/{RepoName}/releaseNotes` (server reads the installed plugin's own pluginInfo.json, dispatches on its declared style) -- the server no longer trusts a client-claimed repo or style, and the same entry point now serves all three modes. The icon (card action row and the detail modal's link list) is gated on PluginReleaseNotesStyle(data) !== 'none' instead of GitHubRepoOf(data), so an undeclared plugin shows nothing rather than a link to a 404. Cross-repo note: `releaseNotesStyle` needs a matching addition to pluginInfo.schema.json / PLUGININFO_FORMAT.md in fpp-plugin-Template (and fpp-data's vendored copy of the schema, used by lint_plugin.py) -- neither lives in this repo. Flagging for darylc rather than editing a repo I don't maintain. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AE12rwWGhsJsyfENBXpBs5 --- www/api/controllers/plugin.php | 186 ++++++++++++++++++++++++++++++--- www/api/index.php | 2 +- www/api/openapi.json | 67 +++++++----- www/css/fpp.css | 6 ++ www/plugins.php | 107 ++++++++++++++----- 5 files changed, 299 insertions(+), 69 deletions(-) diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 48fb31599..9bb6c99ba 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -3195,28 +3195,102 @@ function PluginReleaseNotesCacheFile() return $base . '/tmp/pluginReleaseNotes.cache.json'; } +// Same idea as GitHubRepoOf() in plugins.php's JS, but server-side: parse an +// installed plugin's own srcURL into "owner/repo" for the GitHub API. Kept +// server-side (rather than trusting a client-supplied repo param, the old +// design) so GetPluginReleaseNotes() only ever talks to the repo its own +// installed pluginInfo.json actually points at. +function PluginGitHubRepoOf($pluginInfo) +{ + $url = isset($pluginInfo['srcURL']) ? $pluginInfo['srcURL'] : ''; + if ($url === '') { + return ''; + } + $parts = parse_url($url); + if (!$parts || !isset($parts['host']) || strtolower($parts['host']) !== 'github.com' || !isset($parts['path'])) { + return ''; + } + $seg = array_values(array_filter(explode('/', $parts['path']), function ($s) { + return $s !== ''; + })); + if (count($seg) < 2) { + return ''; + } + $repo = preg_replace('/\.git$/i', '', $seg[1]); + return strtolower($seg[0] . '/' . $repo); +} + +// Read an installed plugin's own pluginInfo.json off disk, or null. Shared by +// every releaseNotes style below so each one is judged on what the plugin +// actually ships, not on anything a client could claim about it. +function ReadInstalledPluginInfo($plugin) +{ + global $settings; + $infoFile = $settings['pluginDirectory'] . '/' . $plugin . '/pluginInfo.json'; + if (!file_exists($infoFile)) { + return null; + } + $data = json_decode(@file_get_contents($infoFile), true); + return is_array($data) ? $data : null; +} + +function PluginReleaseNotesStyleOf($pluginInfo) +{ + $style = isset($pluginInfo['releaseNotesStyle']) ? $pluginInfo['releaseNotesStyle'] : 'none'; + return in_array($style, array('gitRelease', 'gitHistory', 'script'), true) ? $style : 'none'; +} + /** - * Fetch a GitHub-hosted plugin's latest release, proxied server-side. - * - * Same idea as GitOSReleaseNotes() (FPP's own release-notes endpoint, - * hardcoded to FalconChristmas/fpp) but for an arbitrary plugin repo, and - * TTL-cached the same way PluginGitHubStatsCacheFile() is: GitHub's - * unauthenticated API rate limit is shared across every box calling in from - * behind the same NAT, and this keeps api.github.com out of the CSP the way - * every other third-party call in this file already does. + * Show a plugin's release notes, in whichever form it declared via + * `releaseNotesStyle` in its `pluginInfo.json` (see PLUGININFO_FORMAT.md): + * `gitRelease` (latest GitHub Release), `gitHistory` (commits not yet + * pulled, read from the plugin's own clone -- no GitHub API call, works + * for any git-hosted plugin whether or not it tags releases), or `script` + * (runs the plugin's own scripts/fpp_releasenotes.sh). `none` or absent: + * 404, nothing to show -- the Plugin Manager doesn't offer the icon in + * that case either, this is just the server-side half of that gate. * - * @route GET /api/plugin/releaseNotes - * @response 200 The repo's latest release (trimmed to the fields the UI uses) + * @route GET /api/plugin/{RepoName}/releaseNotes + * @response 200 Shape depends on `style` * ```json - * {"name": "v1.2.0", "tag_name": "v1.2.0", "published_at": "2026-01-01T00:00:00Z", "body": "...", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0"} + * {"style": "gitRelease", "name": "v1.2.0", "tag_name": "v1.2.0", "published_at": "2026-01-01T00:00:00Z", "body": "...", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0"} * ``` */ function GetPluginReleaseNotes() { - $repo = isset($_GET['repo']) ? trim($_GET['repo']) : ''; - if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repo)) { - http_response_code(400); - return json(array('status' => 'ERROR', 'message' => 'Invalid repo')); + $plugin = params('RepoName'); + $pluginInfo = ReadInstalledPluginInfo($plugin); + if ($pluginInfo === null) { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'Plugin is not installed')); + } + + $style = PluginReleaseNotesStyleOf($pluginInfo); + switch ($style) { + case 'gitRelease': + return PluginReleaseNotesFromGitHub($plugin, $pluginInfo); + case 'gitHistory': + return PluginReleaseNotesFromGitHistory($plugin, $pluginInfo); + case 'script': + return PluginReleaseNotesFromScript($plugin, $pluginInfo); + default: + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'This plugin has no release notes')); + } +} + +// releaseNotesStyle: gitRelease -- latest GitHub Release, proxied and cached +// server-side. Same idea as GitOSReleaseNotes() (FPP's own release-notes +// endpoint, hardcoded to FalconChristmas/fpp) but for an arbitrary plugin +// repo: GitHub's unauthenticated API rate limit is shared across every box +// calling in from behind the same NAT, and this keeps api.github.com out of +// the CSP the way every other third-party call in this file already does. +function PluginReleaseNotesFromGitHub($plugin, $pluginInfo) +{ + $repo = PluginGitHubRepoOf($pluginInfo); + if ($repo === '') { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'No GitHub repo could be determined for this plugin')); } $cacheFile = PluginReleaseNotesCacheFile(); @@ -3226,7 +3300,7 @@ function GetPluginReleaseNotes() if (is_array($tmp)) $cache = $tmp; } - $key = strtolower($repo); + $key = $repo; // already lowercased by PluginGitHubRepoOf() $now = time(); if (isset($cache[$key]['ts']) && ($now - (int)$cache[$key]['ts']) < PLUGIN_RELEASE_NOTES_CACHE_TTL) { if (!empty($cache[$key]['notFound'])) { @@ -3264,6 +3338,7 @@ function GetPluginReleaseNotes() // carries a lot more (assets, author, reactions, ...) that would just // bloat the cache file for no benefit here. $trimmed = array( + 'style' => 'gitRelease', 'name' => isset($data['name']) ? $data['name'] : '', 'tag_name' => isset($data['tag_name']) ? $data['tag_name'] : '', 'published_at' => isset($data['published_at']) ? $data['published_at'] : '', @@ -3277,6 +3352,85 @@ function GetPluginReleaseNotes() return json($trimmed); } +// releaseNotesStyle: gitHistory -- the commits between the installed clone's +// HEAD and origin/, read directly from the plugin's own repo. No +// GitHub API call (works the same for a non-GitHub git host), and no live +// `git fetch` here either -- this rides whatever origin/ the last +// PluginHasUpdates()/CheckForPluginUpdates() pass already brought in, the +// same "cheap, already-fetched refs" assumption PluginHasUpdates() itself +// makes. Same delimited log format changelog.php uses for FPP's own commit +// history, capped well short of a wall of history for what is meant to +// answer "what's in this update", not serve as a full log viewer. +define('PLUGIN_RELEASE_HISTORY_MAX_COMMITS', 50); + +function PluginReleaseNotesFromGitHistory($plugin, $pluginInfo) +{ + global $settings; + + $branch = PluginCurrentBranch($plugin); + if ($branch === '') { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'Could not determine this plugin\'s branch')); + } + + $dir = $settings['pluginDirectory'] . '/' . $plugin; + $logFormat = '%h%x1f%an%x1f%ai%x1f%s'; + $cmd = 'cd ' . escapeshellarg($dir) + . ' && git log --pretty=format:' . escapeshellarg($logFormat) + . ' HEAD..' . escapeshellarg('origin/' . $branch) + . ' | head -' . PLUGIN_RELEASE_HISTORY_MAX_COMMITS; + exec($cmd, $lines, $return_val); + + $commits = array(); + foreach ($lines as $line) { + $parts = explode("\x1f", $line); + if (count($parts) !== 4) { + continue; + } + $commits[] = array( + 'hash' => $parts[0], + 'author' => $parts[1], + 'date' => $parts[2], + 'subject' => $parts[3], + ); + } + + if (empty($commits)) { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'No new commits found')); + } + + return json(array('style' => 'gitHistory', 'branch' => $branch, 'commits' => $commits)); +} + +// releaseNotesStyle: script -- for a plugin whose update-worthy changes +// aren't in the git log at all (see scripts/fpp_update_check.sh's own +// doc comment on PluginHasUpdates() -- components like Pulshmesh/FPPMon +// that live outside the git repo). Runs scripts/fpp_releasenotes.sh with +// the same FPPDIR/SRCDIR environment fpp_update_check.sh gets, and returns +// its stdout as plain text -- never interpreted as markdown/HTML, so a +// plugin's own script output can't inject markup any more than a GitHub +// release body can (PluginMarkdownToSafeHtml HTML-escapes first; plain +// script output goes through the same escaping on the way to the page). +function PluginReleaseNotesFromScript($plugin, $pluginInfo) +{ + global $settings, $fppDir; + + $script = $settings['pluginDirectory'] . '/' . $plugin . '/scripts/fpp_releasenotes.sh'; + if (!file_exists($script)) { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'This plugin has no scripts/fpp_releasenotes.sh')); + } + + exec('FPPDIR=' . escapeshellarg($fppDir) . ' SRCDIR=' . escapeshellarg($fppDir . '/src') . ' ' . escapeshellarg($script), $lines, $return_val); + if ($return_val != 0 || empty($lines)) { + http_response_code(404); + return json(array('status' => 'ERROR', 'message' => 'No release notes text was returned')); + } + + return json(array('style' => 'script', 'text' => implode("\n", $lines))); +} + /** * Fetch a pluginInfo.json on the browser's behalf * diff --git a/www/api/index.php b/www/api/index.php index 3314d4789..8cd3eaf9c 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -229,11 +229,11 @@ dispatch_get('/plugin/popularity', 'GetPluginPopularity'); // keep above /plugin/:RepoName dispatch_get('/plugin/githubStats', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName dispatch_get('/plugin/source', 'GetPluginSource'); // keep above /plugin/:RepoName -dispatch_get('/plugin/releaseNotes', 'GetPluginReleaseNotes'); // keep above /plugin/:RepoName dispatch_get('/plugin/:RepoName', 'GetPluginInfo'); dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon'); dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl'); dispatch_get('/plugin/:RepoName/privacy', 'GetPluginPrivacyStatus'); +dispatch_get('/plugin/:RepoName/releaseNotes', 'GetPluginReleaseNotes'); dispatch_delete('/plugin/:RepoName', 'UninstallPlugin'); dispatch_get('/plugin/:RepoName/settings/:SettingName', 'PluginGetSetting'); dispatch_put('/plugin/:RepoName/settings/:SettingName', 'PluginSetSetting'); diff --git a/www/api/openapi.json b/www/api/openapi.json index e0d703c19..014d7ca85 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -6902,34 +6902,6 @@ } } }, - "/api/plugin/releaseNotes": { - "get": { - "tags": [ - "plugin" - ], - "summary": "Fetch a GitHub-hosted plugin's latest release, proxied server-side.", - "description": "Same idea as GitOSReleaseNotes() (FPP's own release-notes endpoint, hardcoded to FalconChristmas/fpp) but for an arbitrary plugin repo, and TTL-cached the same way PluginGitHubStatsCacheFile() is: GitHub's unauthenticated API rate limit is shared across every box calling in from behind the same NAT, and this keeps api.github.com out of the CSP the way every other third-party call in this file already does.", - "responses": { - "200": { - "description": "The repo's latest release (trimmed to the fields the UI uses)", - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "name": "v1.2.0", - "tag_name": "v1.2.0", - "published_at": "2026-01-01T00:00:00Z", - "body": "...", - "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0" - } - } - } - } - } - } - }, "/api/plugin/source": { "get": { "tags": [ @@ -7128,6 +7100,45 @@ } } }, + "/api/plugin/{RepoName}/releaseNotes": { + "parameters": [ + { + "name": "RepoName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "plugin" + ], + "summary": "plugin/{RepoName}/releaseNotes", + "description": "Show a plugin's release notes, in whichever form it declared via `releaseNotesStyle` in its `pluginInfo.json` (see PLUGININFO_FORMAT.md): `gitRelease` (latest GitHub Release), `gitHistory` (commits not yet pulled, read from the plugin's own clone -- no GitHub API call, works for any git-hosted plugin whether or not it tags releases), or `script` (runs the plugin's own scripts/fpp_releasenotes.sh). `none` or absent: 404, nothing to show -- the Plugin Manager doesn't offer the icon in that case either, this is just the server-side half of that gate.", + "responses": { + "200": { + "description": "Shape depends on `style`", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "style": "gitRelease", + "name": "v1.2.0", + "tag_name": "v1.2.0", + "published_at": "2026-01-01T00:00:00Z", + "body": "...", + "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0" + } + } + } + } + } + } + }, "/api/plugin/{RepoName}/settings/{SettingName}": { "parameters": [ { diff --git a/www/css/fpp.css b/www/css/fpp.css index 50531270a..b0445292e 100644 --- a/www/css/fpp.css +++ b/www/css/fpp.css @@ -4644,6 +4644,12 @@ pre.testData, .pluginReleaseNotesLink:hover { color: var(--bs-link-color); } +/* releaseNotesStyle: script -- plain-text release notes, wrapped rather than + scrolling horizontally like a code block would. */ +pre.release-notes-body { + white-space: pre-wrap; + word-break: break-word; +} /* Popular Plugins: single horizontally-scrollable row (functional scroll boundary + fixed item width so flex children don't collapse; no Bootstrap utility covers a fixed rem width). Scroll is contained here so the page diff --git a/www/plugins.php b/www/plugins.php index b46572b68..d3a61871f 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -2215,11 +2215,24 @@ function GitHubStatsRowHtml(repo) { return '
    ' + badge + '
    '; } + // Whether/how the Plugin Manager offers this plugin's release notes -- + // author-declared via `releaseNotesStyle` in pluginInfo.json (see + // PLUGININFO_FORMAT.md), not inferred from whether the repo happens to be + // on GitHub. Most plugins have no tagged GitHub Releases at all, so + // gating on "is this GitHub-hosted" (the original approach here) meant + // showing an icon that was a dead end for almost every plugin. Explicit + // opt-in means: no icon for a plugin that hasn't declared a style, and no + // upfront round trip needed to find that out ahead of render time. + function PluginReleaseNotesStyle(data) { + var style = data && data.releaseNotesStyle; + return (style === 'gitRelease' || style === 'gitHistory' || style === 'script') ? style : 'none'; + } + // Release-notes trigger, wrapped for inline placement at the right end of // the action row alongside (or in place of) the GitHub stats badge -- same // ms-auto pattern as GitHubStatsRowHtml, so the two cluster together at the - // trailing edge whether or not the stats badge is also present. '' when the - // plugin isn't GitHub-hosted (GitHubRepoOf() can't derive an owner/repo). + // trailing edge whether or not the stats badge is also present. '' when + // the plugin hasn't opted into a releaseNotesStyle. // Shown at every UI level: unlike the stats badge this needs no upfront API // call (ShowPluginReleaseNotes fetches on click), so there's no rate-limit // reason to gate it behind Advanced/Developer. @@ -2227,7 +2240,7 @@ function GitHubStatsRowHtml(repo) { // ShowPluginReleaseNotes) via the card's existing data-plugin-action // delegated click handler, same as the Install/Update/Uninstall buttons. function ReleaseNotesRowHtml(data) { - if (!GitHubRepoOf(data)) return ''; + if (PluginReleaseNotesStyle(data) === 'none') return ''; return '' + ''; @@ -2267,15 +2280,60 @@ function PluginMarkdownToSafeHtml(md) { return '

    ' + body + '

    '; } - // Fetch and show a plugin's latest GitHub release in a modal -- same shape - // as about.php's OS-release-notes flow (loading spinner, then patch - // .modal-body once the proxied api/plugin/releaseNotes call resolves). + // releaseNotesStyle: gitRelease renderer. + function RenderGitReleaseNotes(release, ghRepo) { + var html = ''; + if (release.published_at) { + var date = new Date(release.published_at); + html += '

    Published: ' + date.toLocaleDateString() + '

    '; + } + html += release.body + ? '
    ' + PluginMarkdownToSafeHtml(release.body) + '
    ' + : '

    No release notes text was provided for this release.

    '; + if (IsSafeHttpUrl(release.html_url)) { + html += ''; + } + return html; + } + + // releaseNotesStyle: gitHistory renderer -- same commit-row shape + // changelog.php uses for FPP's own history, just without the "Current" + // highlight (there's no local commit to compare against here, only what + // the update would bring in). + function RenderGitHistoryReleaseNotes(release) { + if (!release.commits || !release.commits.length) { + return '

    No new commits were found.

    '; + } + var html = ''; + release.commits.forEach(function (c) { + html += ''; + }); + html += '
    CommitMessage
    ' + EscapeHtml(c.hash) + '' + EscapeHtml(c.subject) + + '
    ' + EscapeHtml(c.author) + ' · ' + EscapeHtml(c.date) + '
    '; + return html; + } + + // releaseNotesStyle: script renderer -- plain text from the plugin's own + // scripts/fpp_releasenotes.sh, HTML-escaped and line-wrapped. Never + // interpreted as markdown/HTML, same reasoning as PluginMarkdownToSafeHtml: + // a plugin's own script output shouldn't be able to inject markup either. + function RenderScriptReleaseNotes(release) { + if (!release.text) { + return '

    No release notes text was returned.

    '; + } + return '
    ' + EscapeHtml(release.text) + '
    '; + } + + // Fetch and show a plugin's release notes in a modal, in whichever style + // it declared (see PluginReleaseNotesStyle) -- same shape as about.php's + // OS-release-notes flow (loading spinner, then patch .modal-body once + // api/plugin/{RepoName}/releaseNotes resolves). function ShowPluginReleaseNotes(repo) { var i = FindPluginInfo(repo); if (i < 0) return; var data = pluginInfos[i]; - var ghRepo = GitHubRepoOf(data); - if (!ghRepo) return; + if (PluginReleaseNotesStyle(data) === 'none') return; // This link also lives inside the plugin detail modal, so a click there // would otherwise stack a second Bootstrap modal on top of it. Close it @@ -2296,29 +2354,30 @@ class: 'modal-lg modal-dialog-scrollable', backdrop: true }); + var ghRepo = GitHubRepoOf(data); + $.ajax({ - url: 'api/plugin/releaseNotes?repo=' + encodeURIComponent(ghRepo), + url: 'api/plugin/' + encodeURIComponent(repo) + '/releaseNotes', dataType: 'json', success: function (release) { - var html = ''; - if (release.published_at) { - var date = new Date(release.published_at); - html += '

    Published: ' + date.toLocaleDateString() + '

    '; - } - html += release.body - ? '
    ' + PluginMarkdownToSafeHtml(release.body) + '
    ' - : '

    No release notes text was provided for this release.

    '; - if (IsSafeHttpUrl(release.html_url)) { - html += ''; + var html; + if (release.style === 'gitHistory') { + html = RenderGitHistoryReleaseNotes(release); + } else if (release.style === 'script') { + html = RenderScriptReleaseNotes(release); + } else { + html = RenderGitReleaseNotes(release, ghRepo); } $('#pluginReleaseNotesModal .modal-body').html(html); }, error: function () { var html = '
    ' + - '
    No GitHub releases found for this plugin.
    ' + - '
    '; + '
    No release notes are available for this plugin right now.
    '; + if (ghRepo) { + html += ''; + } + html += ''; $('#pluginReleaseNotesModal .modal-body').html(html); } }); @@ -2671,7 +2730,7 @@ function ShowPluginDetail(repo) { }; if (IsSafeHttpUrl(data.srcURL) && !sameLink(data.srcURL, data.homeURL)) body += ' View Source'; if (IsSafeHttpUrl(data.bugURL)) body += ' Report a Bug'; - if (GitHubRepoOf(data)) body += ' Release Notes'; + if (PluginReleaseNotesStyle(data) !== 'none') body += ' Release Notes'; body += ''; // What a plugin declares is one tap away from its card at any // time, not only at install. Lines open on tap here too. For a