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 '';
+ });
+
+ 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 = '| Commit | Message |
';
+ release.commits.forEach(function (c) {
+ html += '' + EscapeHtml(c.hash) + ' | ' + EscapeHtml(c.subject) +
+ ' ' + EscapeHtml(c.author) + ' · ' + EscapeHtml(c.date) + ' |
';
+ });
+ html += '
';
+ 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