diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 54d2f373e..9bb6c99ba 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -3186,6 +3186,251 @@ 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'; +} + +// 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'; +} + +/** + * 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/{RepoName}/releaseNotes + * @response 200 Shape depends on `style` + * ```json + * {"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() +{ + $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(); + $cache = array(); + if (file_exists($cacheFile)) { + $tmp = json_decode(@file_get_contents($cacheFile), true); + if (is_array($tmp)) $cache = $tmp; + } + + $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'])) { + 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( + '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'] : '', + '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); +} + +// 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 3ce71ff78..8cd3eaf9c 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -233,6 +233,7 @@ 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 310af5d00..014d7ca85 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -7100,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 76d2d14e2..b0445292e 100644 --- a/www/css/fpp.css +++ b/www/css/fpp.css @@ -4635,6 +4635,21 @@ 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); +} +/* 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 7436d7baa..d3a61871f 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -2215,6 +2215,174 @@ 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 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. + // 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) { + if (PluginReleaseNotesStyle(data) === 'none') 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 + '

    '; + } + + // 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]; + 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 + // 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 + }); + + var ghRepo = GitHubRepoOf(data); + + $.ajax({ + url: 'api/plugin/' + encodeURIComponent(repo) + '/releaseNotes', + dataType: 'json', + success: function (release) { + 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 release notes are available for this plugin right now.
    '; + if (ghRepo) { + html += ''; + } + html += '
    '; + $('#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 @@ -2562,6 +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 (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 @@ -2781,7 +2950,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 + PrivacyRowHtml(data) + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + '
    '; + actions + ReleaseNotesRowHtml(data) + PrivacyRowHtml(data) + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + ''; html += ''; if (installed) { @@ -3215,6 +3384,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. });