mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
* feat(dashboard): version indicator + GHCR update check with admin panel - Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter) - Extend /api/version with latest_version and update_available - Add POST /api/admin/check-update (force GHCR poll) - Add POST /api/admin/trigger-update (Docker compose or manual instructions) - Sidebar footer: version label + amber badge when update available - Admin > System: version comparison card with Check/Update buttons - 14 new tests (10 unit + 4 integration), 68/68 passing Closes #163 * fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout Review follow-up on #165 (the two blockers): - trigger-update runs `docker compose up -d` on the HOST via docker.sock (root-equivalent) but was behind requireAdmin, i.e. reachable by any workspace-level admin. On a multi-tenant host that's a customer, not the infra operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates it further). check-update stays requireAdmin — it's a read-only GHCR poll. - ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default timeout, so a hung GHCR connection never settled — leaving `inFlight` set forever (the finally never ran), which wedged the background poller AND hung any awaited checkNow (/api/admin/check-update). Add a 10s AbortController timeout on both requests so the try/catch/finally always fire. All 405 server tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ScreenTinker <hello@screentinker.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
5.9 KiB
JavaScript
172 lines
5.9 KiB
JavaScript
'use strict';
|
|
|
|
// In-memory cache for the latest version discovered from GHCR. Restart = fresh poll.
|
|
// 36h interval means ~1 request per deploy cycle — no persistence needed.
|
|
|
|
let latestVersion = null; // string | null — highest semver tag found
|
|
let checkedAt = null; // number (epoch ms) | null — last poll timestamp
|
|
let inFlight = null; // Promise | null — dedup concurrent polls
|
|
|
|
// Extract semver x.y.z tags from a tag list array, ignoring pre-release suffixes
|
|
// and non-semver labels. Returns tags sorted descending (latest first) by numeric
|
|
// component comparison so [0] is the highest version.
|
|
function extractSemverTags(tags) {
|
|
if (!Array.isArray(tags) || tags.length === 0) return [];
|
|
|
|
// Must match the ENTIRE string — pre-release suffixes (-beta, -rc1) are excluded
|
|
const semverRegex = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
const parsed = [];
|
|
|
|
for (const tag of tags) {
|
|
const m = tag.match(semverRegex);
|
|
if (!m) continue;
|
|
parsed.push({ tag, major: +m[1], minor: +m[2], patch: +m[3] });
|
|
}
|
|
|
|
parsed.sort((a, b) => {
|
|
if (a.major !== b.major) return b.major - a.major;
|
|
if (a.minor !== b.minor) return b.minor - a.minor;
|
|
return b.patch - a.patch;
|
|
});
|
|
|
|
return parsed.map(p => p.tag);
|
|
}
|
|
|
|
// Compare two semver strings element-wise. Returns:
|
|
// negative → a < b
|
|
// 0 → a == b
|
|
// positive → a > b
|
|
// NaN → one or both inputs are not semver
|
|
function compareVersions(a, b) {
|
|
// Match the ENTIRE string — pre-release suffixes rejected
|
|
const ra = a.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
const rb = b.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
if (!ra || !rb) return NaN;
|
|
|
|
for (let i = 1; i <= 3; i++) {
|
|
const diff = +ra[i] - +rb[i];
|
|
if (diff !== 0) return diff;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Synchronous cache reader. Returns null before the first poll completes.
|
|
function getLatestVersion() {
|
|
return latestVersion;
|
|
}
|
|
|
|
// Hard per-request cap. Node's global fetch has NO default timeout, so a hung GHCR
|
|
// connection would otherwise never settle — leaving `inFlight` set forever (the finally
|
|
// never runs) and wedging BOTH the background poller and any awaited checkNow (e.g.
|
|
// /api/admin/check-update). AbortController makes a stalled fetch reject so the outer
|
|
// try/catch/finally always fire and the cache keeps serving.
|
|
const FETCH_TIMEOUT_MS = 10000;
|
|
async function fetchWithTimeout(url, opts) {
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, { ...(opts || {}), signal: ctrl.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
// Force a fresh GHCR poll (bypasses cache), fetch tags, extract the highest
|
|
// semver, and update the cache. Returns { latest, update_available }.
|
|
async function checkNow(currentVersion) {
|
|
// De-duplicate: if a poll is already in flight, wait for it instead of
|
|
// starting a second concurrent fetch.
|
|
if (inFlight) return inFlight;
|
|
|
|
inFlight = (async () => {
|
|
try {
|
|
// Step 1: get anonymous OAuth token for GHCR public repo access
|
|
const tokenRes = await fetchWithTimeout(
|
|
'https://ghcr.io/token?scope=repository:screentinker/screentinker:pull'
|
|
);
|
|
if (!tokenRes.ok) throw new Error(`GHCR token endpoint returned ${tokenRes.status}`);
|
|
const { token } = await tokenRes.json();
|
|
if (!token) throw new Error('GHCR token response missing token field');
|
|
|
|
// Step 2: list tags with Bearer auth
|
|
const tagsUrl = 'https://ghcr.io/v2/screentinker/screentinker/tags/list';
|
|
const tagsRes = await fetchWithTimeout(tagsUrl, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
// Some registries return 401/404 if no tags exist yet — not an error,
|
|
// just means no releases published.
|
|
if (tagsRes.status === 401 || tagsRes.status === 404) {
|
|
latestVersion = null;
|
|
checkedAt = Date.now();
|
|
return { latest: null, update_available: false };
|
|
}
|
|
|
|
if (!tagsRes.ok) throw new Error(`GHCR tags endpoint returned ${tagsRes.status}`);
|
|
|
|
const body = await tagsRes.json();
|
|
|
|
// Handle both { tags: [...] } and { name, tags: [...] } response shapes
|
|
const tags = body.tags || [];
|
|
if (!Array.isArray(tags) || tags.length === 0) {
|
|
latestVersion = null;
|
|
checkedAt = Date.now();
|
|
return { latest: null, update_available: false };
|
|
}
|
|
|
|
// Extract and sort semver tags
|
|
const semverTags = extractSemverTags(tags);
|
|
if (semverTags.length === 0) {
|
|
latestVersion = null;
|
|
checkedAt = Date.now();
|
|
return { latest: null, update_available: false };
|
|
}
|
|
|
|
// The highest version is the first element (sorted descending)
|
|
const latest = semverTags[0];
|
|
latestVersion = latest;
|
|
checkedAt = Date.now();
|
|
|
|
const updateAvailable = currentVersion
|
|
? compareVersions(latest, currentVersion) > 0
|
|
: false;
|
|
|
|
return { latest, update_available: updateAvailable };
|
|
} catch (err) {
|
|
// Network errors, DNS failures, etc. — silent, cache stays as-is.
|
|
// The next poll will retry; the existing cache (if any) still serves.
|
|
console.error('[ghcr-check] poll failed:', err.message);
|
|
return { latest: latestVersion, update_available: false };
|
|
} finally {
|
|
// Clear in-flight guard so next poll can run
|
|
inFlight = null;
|
|
}
|
|
})();
|
|
|
|
return inFlight;
|
|
}
|
|
|
|
// Start background polling at the given interval (in hours). First poll fires
|
|
// after a 30s initial delay to let the server stabilize.
|
|
function startPolling(intervalHours, currentVersion) {
|
|
const intervalMs = intervalHours * 60 * 60 * 1000;
|
|
|
|
// Initial poll after 30s
|
|
setTimeout(() => {
|
|
checkNow(currentVersion).catch(() => {});
|
|
}, 30000);
|
|
|
|
// Periodic poll
|
|
setInterval(() => {
|
|
checkNow(currentVersion).catch(() => {});
|
|
}, intervalMs);
|
|
}
|
|
|
|
module.exports = {
|
|
extractSemverTags,
|
|
compareVersions,
|
|
getLatestVersion,
|
|
checkNow,
|
|
startPolling,
|
|
};
|