diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index 0c302b5..468bab6 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -126,6 +126,7 @@ jobs: mkdir -p "$artifact_directory" docker buildx build \ + --build-arg "RENTNERPROXY_BUILD_VERSION=$IMMUTABLE_TAG" \ --file source/docker/production/Dockerfile \ --label 'org.opencontainers.image.title=RentnerProxy PR Preview' \ --label 'org.opencontainers.image.description=Unreviewed pull request preview for testing only.' \ diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index eee0967..2f0e365 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -330,6 +330,7 @@ jobs: with: cache-from: type=gha,scope=release-${{ inputs.channel }} cache-to: type=gha,mode=max,scope=release-${{ inputs.channel }} + build-args: RENTNERPROXY_BUILD_VERSION=${{ inputs.release_tag }} context: source file: source/docker/production/Dockerfile labels: ${{ steps.metadata.outputs.labels }} diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index be0ef60..c8c29c3 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -9,7 +9,8 @@ RUN bun install --frozen-lockfile FROM web-dependencies AS web-build COPY . . -RUN bun run build:web \ +ARG RENTNERPROXY_BUILD_VERSION +RUN RENTNERPROXY_BUILD_VERSION="$RENTNERPROXY_BUILD_VERSION" bun run build:web \ && bun build web/src/db/migrate.ts --target=bun --outfile=/build/migrate.js FROM oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 AS web-runtime diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 64b5b21..3a121bf 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -7,7 +7,8 @@ RUN bun install --frozen-lockfile FROM dependencies AS build COPY . . -RUN bun run build:web +ARG RENTNERPROXY_BUILD_VERSION +RUN RENTNERPROXY_BUILD_VERSION="$RENTNERPROXY_BUILD_VERSION" bun run build:web RUN bun build web/src/db/migrate.ts --target=bun --outfile=/build/migrate.js FROM oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 AS runtime diff --git a/web/src/config/version.config.ts b/web/src/config/version.config.ts new file mode 100644 index 0000000..8344ff2 --- /dev/null +++ b/web/src/config/version.config.ts @@ -0,0 +1,4 @@ +declare const RENTNERPROXY_BUILD_VERSION: string + +export const APP_VERSION = + typeof RENTNERPROXY_BUILD_VERSION === 'string' ? RENTNERPROXY_BUILD_VERSION : '0.0.0-dev' diff --git a/web/src/features/ApplicationVersion/Hooks/useApplicationVersionLogic.ts b/web/src/features/ApplicationVersion/Hooks/useApplicationVersionLogic.ts new file mode 100644 index 0000000..f795eb3 --- /dev/null +++ b/web/src/features/ApplicationVersion/Hooks/useApplicationVersionLogic.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' + +import { getApplicationUpdateHandler } from '../server' + +export default function useApplicationVersionLogic() { + return useQuery({ + queryKey: ['application-update'], + queryFn: () => getApplicationUpdateHandler(), + staleTime: 60 * 60 * 1000, + refetchInterval: 60 * 60 * 1000, + refetchIntervalInBackground: false, + retry: false, + }) +} diff --git a/web/src/features/ApplicationVersion/server.ts b/web/src/features/ApplicationVersion/server.ts new file mode 100644 index 0000000..423fd87 --- /dev/null +++ b/web/src/features/ApplicationVersion/server.ts @@ -0,0 +1,11 @@ +import { createServerFn } from '@tanstack/react-start' + +import { PERMISSIONS } from '../../config/permissions.config' +import { APP_VERSION } from '../../config/version.config' +import { requirePermissionService } from '../../server/Auth/Access/authorization.service' +import { getAvailableUpdate } from '../../server/Updates/releases.service' + +export const getApplicationUpdateHandler = createServerFn({ method: 'GET' }).handler(async () => { + await requirePermissionService(PERMISSIONS.APP_ACCESS) + return { latestVersion: await getAvailableUpdate(APP_VERSION) } +}) diff --git a/web/src/language/Locales/de.json b/web/src/language/Locales/de.json index 4a84bba..84b3466 100644 --- a/web/src/language/Locales/de.json +++ b/web/src/language/Locales/de.json @@ -49,7 +49,9 @@ "certificates": "Zertifikate", "accessPolicies": "Zugriffsrichtlinien", "proxyAccessLogs": "Zugriffsprotokolle", - "auditLogs": "Audit-Protokoll" + "auditLogs": "Audit-Protokoll", + "systemVersion": "Systemversion {{version}}", + "updateAvailable": "Update verfügbar auf Version {{version}}" }, "language": { "title": "Sprache", diff --git a/web/src/language/Locales/en.json b/web/src/language/Locales/en.json index 3437e71..d2538e0 100644 --- a/web/src/language/Locales/en.json +++ b/web/src/language/Locales/en.json @@ -49,7 +49,9 @@ "certificates": "Certificates", "accessPolicies": "Access policies", "proxyAccessLogs": "Access logs", - "auditLogs": "Audit log" + "auditLogs": "Audit log", + "systemVersion": "System version {{version}}", + "updateAvailable": "Update available to version {{version}}" }, "language": { "title": "Language", diff --git a/web/src/language/Locales/es.json b/web/src/language/Locales/es.json index 5cbcc90..d4909f6 100644 --- a/web/src/language/Locales/es.json +++ b/web/src/language/Locales/es.json @@ -49,7 +49,9 @@ "certificates": "Certificados", "accessPolicies": "Políticas de acceso", "proxyAccessLogs": "Registros de acceso", - "auditLogs": "Registro de auditoría" + "auditLogs": "Registro de auditoría", + "systemVersion": "Versión del sistema {{version}}", + "updateAvailable": "Actualización disponible a la versión {{version}}" }, "language": { "title": "Idioma", diff --git a/web/src/language/Locales/fr.json b/web/src/language/Locales/fr.json index f11c988..73a1572 100644 --- a/web/src/language/Locales/fr.json +++ b/web/src/language/Locales/fr.json @@ -49,7 +49,9 @@ "certificates": "Certificats", "accessPolicies": "Politiques d’accès", "proxyAccessLogs": "Journaux d’accès", - "auditLogs": "Journal d’audit" + "auditLogs": "Journal d’audit", + "systemVersion": "Version du système {{version}}", + "updateAvailable": "Mise à jour disponible vers la version {{version}}" }, "language": { "title": "Langue", diff --git a/web/src/layout/Components/ApplicationShell/Components/ApplicationVersion.tsx b/web/src/layout/Components/ApplicationShell/Components/ApplicationVersion.tsx new file mode 100644 index 0000000..6d5ae37 --- /dev/null +++ b/web/src/layout/Components/ApplicationShell/Components/ApplicationVersion.tsx @@ -0,0 +1,36 @@ +import { ArrowUp } from 'lucide-react' + +import { APP_VERSION } from '../../../../config/version.config' +import useTranslationStore from '../../../../language/useTranslationStore' +import { Tooltip } from '../../../../shared/Tooltip' +import useApplicationVersionLogic from '../../../../features/ApplicationVersion/Hooks/useApplicationVersionLogic' + +export default function ApplicationVersion() { + const { t } = useTranslationStore() + const { data } = useApplicationVersionLogic() + const updateLabel = t('shell.updateAvailable', { version: data?.latestVersion }) + + return ( +
+ + {APP_VERSION.startsWith('v') ? APP_VERSION : `v${APP_VERSION}`} + + {data?.latestVersion ? ( + + + + + ) : null} +
+ ) +} diff --git a/web/src/layout/Components/ApplicationShell/Styles/applicationShellClassNames.ts b/web/src/layout/Components/ApplicationShell/Styles/applicationShellClassNames.ts index d9707bf..3dd04b4 100644 --- a/web/src/layout/Components/ApplicationShell/Styles/applicationShellClassNames.ts +++ b/web/src/layout/Components/ApplicationShell/Styles/applicationShellClassNames.ts @@ -24,7 +24,7 @@ export function getApplicationTopbarClassName(isNavigationExpanded: boolean): st export const applicationShellClassNames = { sidebar: { - content: `relative z-20 flex min-h-0 flex-1 flex-col gap-5 p-4 pb-24 shell:gap-6 shell:py-7 shell:pr-[2.75rem] shell:pl-[1.35rem] ${mobileSurfaceMaskClassName} ${desktopContentMaskClassName}`, + content: `relative z-20 flex min-h-0 flex-1 flex-col gap-5 p-4 pb-24 shell:gap-6 shell:pt-7 shell:pb-1 shell:pr-[2.75rem] shell:pl-[1.35rem] ${mobileSurfaceMaskClassName} ${desktopContentMaskClassName}`, logoLink: 'block w-fit max-w-[13rem] cursor-pointer rounded-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-400 shell:max-w-[14rem]', logoImage: 'block h-auto w-full', diff --git a/web/src/layout/Components/ApplicationShell/index.tsx b/web/src/layout/Components/ApplicationShell/index.tsx index a0f7fe9..6361dc7 100644 --- a/web/src/layout/Components/ApplicationShell/index.tsx +++ b/web/src/layout/Components/ApplicationShell/index.tsx @@ -6,6 +6,7 @@ import ApplicationNavigation from './Components/ApplicationNavigation' import ApplicationSidebarSurface from './Components/ApplicationSidebarSurface' import ApplicationTopbar from './Components/ApplicationTopbar' import ApplicationUserPanel from './Components/ApplicationUserPanel' +import ApplicationVersion from './Components/ApplicationVersion' import getApplicationShellLayoutClassNames from './Helpers/getApplicationShellLayoutClassNames' import getApplicationShellViewModel from './Helpers/getApplicationShellViewModel' import useApplicationNavigationLogic from './Hooks/useApplicationNavigationLogic' @@ -61,6 +62,7 @@ export default function AuthenticatedShell({ onLogout={onLogout} user={user} /> + diff --git a/web/src/server/Updates/releases.service.ts b/web/src/server/Updates/releases.service.ts new file mode 100644 index 0000000..bbca101 --- /dev/null +++ b/web/src/server/Updates/releases.service.ts @@ -0,0 +1,67 @@ +import { semver } from 'bun' +import { z } from 'zod' + +const releasesSchema = z.array( + z.object({ + tag_name: z.string().max(128), + draft: z.boolean(), + prerelease: z.boolean(), + }), +) +const versionPattern = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ +let cached: unknown = [] +let expiresAt = 0 +let pending: Promise | undefined + +export function findAvailableUpdate(currentVersion: string, releases: unknown): string | null { + if (!versionPattern.test(currentVersion) || currentVersion.includes('-dev')) return null + const parsed = releasesSchema.safeParse(releases) + if (!parsed.success) return null + const current = currentVersion.replace(/^v/, '') + const allowsPrerelease = current.split('+')[0]!.includes('-') + return ( + parsed.data + .filter((release) => !release.draft && versionPattern.test(release.tag_name)) + .map((release) => ({ + prerelease: release.prerelease, + version: release.tag_name.replace(/^v/, ''), + })) + .filter( + (release) => + (allowsPrerelease || + (!release.prerelease && !release.version.split('+')[0]!.includes('-'))) && + semver.order(release.version, current) > 0, + ) + .toSorted((a, b) => semver.order(b.version, a.version))[0]?.version ?? null + ) +} + +export async function getAvailableUpdate(currentVersion: string): Promise { + if (currentVersion.includes('-dev') || !versionPattern.test(currentVersion)) return null + if (Date.now() >= expiresAt) { + pending ??= (async () => { + try { + const response = await fetch( + 'https://api.github.com/repos/RentnerKev/RentnerProxy/releases?per_page=100', + { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'RentnerProxy', + }, + signal: AbortSignal.timeout(5000), + }, + ) + if (!response.ok) throw new Error('Release check failed') + cached = releasesSchema.parse(await response.json()) + expiresAt = Date.now() + 60 * 60 * 1000 + } catch { + expiresAt = Date.now() + 5 * 60 * 1000 + } finally { + pending = undefined + } + })() + await pending + } + return findAvailableUpdate(currentVersion, cached) +} diff --git a/web/src/tests/application-version.test.ts b/web/src/tests/application-version.test.ts new file mode 100644 index 0000000..ce6cd1c --- /dev/null +++ b/web/src/tests/application-version.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' + +import { findAvailableUpdate } from '../server/Updates/releases.service' + +const release = (tag_name: string, prerelease = false, draft = false) => ({ + tag_name, + prerelease, + draft, +}) + +describe('application update selection', () => { + test('compares versions numerically and ignores drafts and invalid tags', () => { + expect( + findAvailableUpdate('v1.0.9', [ + release('v1.0.10'), + release('v9.0.0', false, true), + release('preview-123'), + ]), + ).toBe('1.0.10') + }) + test('stable installations do not advertise prereleases', () => { + expect(findAvailableUpdate('1.0.0', [release('v2.0.0-beta.1', true)])).toBeNull() + }) + test('prereleases advance numerically and can upgrade to stable', () => { + expect(findAvailableUpdate('v1.0.0-beta.2', [release('v1.0.0-beta.10', true)])).toBe( + '1.0.0-beta.10', + ) + expect(findAvailableUpdate('v1.0.0-beta.2', [release('v1.0.0')])).toBe('1.0.0') + }) + test('equal, older, development and malformed versions have no update', () => { + for (const version of ['1.0.0', '2.0.0', '0.0.0-dev', 'preview-123']) { + expect(findAvailableUpdate(version, [release('v1.0.0')])).toBeNull() + } + expect(findAvailableUpdate('1.0.0', {})).toBeNull() + }) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 1061c6f..285ae9c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { builtinModules } from 'node:module' import { fileURLToPath } from 'node:url' @@ -19,6 +20,13 @@ const serverBuiltins = [ ] export default defineConfig({ + define: { + RENTNERPROXY_BUILD_VERSION: JSON.stringify( + process.env.RENTNERPROXY_BUILD_VERSION || + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + .version + '-dev', + ), + }, root: webRoot, envDir: repositoryRoot, cacheDir: cacheDirectory,