release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list and the upgrade notes. Ten additions — the SEO package (site URL, robots.txt, sitemap.xml, JSON-LD), slideshow mode, opt-in original downloads, copy-link, previous/next album navigation, alt text from the Immich description, per-album location precision, the update notice, a Help tab, and a subpage cover grid separate from its photo grid. Two fixes: CJK album slugs, and the lightbox control bar. Photo of the Day (#476) was built on `dev` and reverted as not planned; it never appears in a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
134
lib/updateCheck.ts
Normal file
134
lib/updateCheck.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Update notice for the admin dashboard (#496).
|
||||
*
|
||||
* Self-hosted software that never mentions a new release gets run for months
|
||||
* on an old version, which matters here because there have already been
|
||||
* security releases.
|
||||
*
|
||||
* The check is a single call to the GitHub releases API, answered from a
|
||||
* day-long cache, and switchable off with `UPDATE_CHECK=false`. It fails
|
||||
* quietly in every direction: no network, a rate limit, a malformed answer or
|
||||
* an offline instance must never cost the dashboard its other information.
|
||||
*/
|
||||
|
||||
import { env } from './env';
|
||||
import pkg from '../package.json';
|
||||
|
||||
/** The version this instance is running. */
|
||||
export const CURRENT_VERSION: string = pkg.version;
|
||||
|
||||
const RELEASES_URL = 'https://api.github.com/repos/ralksta/immich-folio/releases/latest';
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface UpdateStatus {
|
||||
current: string;
|
||||
latest: string | null;
|
||||
/** Null when the check is off, or nothing is known yet. */
|
||||
updateAvailable: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two dot-separated versions numerically.
|
||||
*
|
||||
* Returns a negative number when `a` is older, positive when newer, 0 when
|
||||
* equal. A leading `v` is tolerated because that is how the releases API spells
|
||||
* a tag. Anything after a `-` is dropped: a pre-release is compared on its
|
||||
* numbers alone, which is enough to decide whether to mention it.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const parts = (version: string) =>
|
||||
version
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
.split('+')[0]
|
||||
.split('-')[0]
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
|
||||
const left = parts(a);
|
||||
const right = parts(b);
|
||||
const length = Math.max(left.length, right.length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const difference = (left[i] ?? 0) - (right[i] ?? 0);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Whether `latest` is worth telling the operator about. */
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the tag out of a releases API payload.
|
||||
*
|
||||
* Written against `unknown` because this is a third-party response: a shape
|
||||
* that changed, or an error object served with a 200, must yield null rather
|
||||
* than throw inside the dashboard.
|
||||
*/
|
||||
export function parseLatestTag(payload: unknown): string | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null;
|
||||
const tag = (payload as { tag_name?: unknown }).tag_name;
|
||||
if (typeof tag !== 'string') return null;
|
||||
const trimmed = tag.trim();
|
||||
// A tag that is not a version cannot be compared, and guessing would be
|
||||
// worse than saying nothing. Anchored at both ends: guarding only the start
|
||||
// let anything beginning with a digit through, and a date-style tag then
|
||||
// read as version 2024 and left every instance showing an update that does
|
||||
// not exist. A pre-release suffix is allowed but may not itself contain a
|
||||
// '-', which is what keeps "2024-01-05" out; build metadata is allowed and
|
||||
// ignored when ordering, as semver says.
|
||||
return /^v?\d+(\.\d+)*(-[0-9A-Za-z.]+)?(\+[0-9A-Za-z.]+)?$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
let cached: { latest: string | null; at: number } | null = null;
|
||||
|
||||
/** Drop the memoised answer. Exposed for tests. */
|
||||
export function resetUpdateCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest published version, or null if it is not known.
|
||||
*
|
||||
* Cached for a day whether the answer was a version or a failure, so a
|
||||
* dashboard left open does not hammer the API and an outage is not retried on
|
||||
* every page load.
|
||||
*/
|
||||
async function fetchLatestVersion(): Promise<string | null> {
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.latest;
|
||||
|
||||
let latest: string | null = null;
|
||||
try {
|
||||
const response = await fetch(RELEASES_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.ok) latest = parseLatestTag(await response.json());
|
||||
} catch {
|
||||
// Offline, blocked, rate-limited or timed out. The dashboard simply does
|
||||
// not mention updates this time.
|
||||
latest = null;
|
||||
}
|
||||
|
||||
cached = { latest, at: Date.now() };
|
||||
return latest;
|
||||
}
|
||||
|
||||
/** The update status for the admin dashboard. Never throws. */
|
||||
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||
if (!env.UPDATE_CHECK) {
|
||||
return { current: CURRENT_VERSION, latest: null, updateAvailable: null };
|
||||
}
|
||||
|
||||
const latest = await fetchLatestVersion();
|
||||
return {
|
||||
current: CURRENT_VERSION,
|
||||
latest,
|
||||
updateAvailable: latest === null ? null : isNewerVersion(latest, CURRENT_VERSION),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user