Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr-preview-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.' \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 2 additions & 1 deletion docker/production/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docker/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions web/src/config/version.config.ts
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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,
})
}
11 changes: 11 additions & 0 deletions web/src/features/ApplicationVersion/server.ts
Original file line number Diff line number Diff line change
@@ -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) }
})
4 changes: 3 additions & 1 deletion web/src/language/Locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion web/src/language/Locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion web/src/language/Locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion web/src/language/Locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="-mt-4 -ml-3 flex min-h-4 shrink-0 items-center justify-start gap-1.5 text-[0.65rem] leading-4 shell:-mt-5 shell:-ml-4 text-mist-400">
<span
aria-label={t('shell.systemVersion', { version: APP_VERSION })}
className="font-mono tabular-nums"
>
{APP_VERSION.startsWith('v') ? APP_VERSION : `v${APP_VERSION}`}
</span>
{data?.latestVersion ? (
<Tooltip content={updateLabel}>
<a
href="https://github.com/RentnerKev/RentnerProxy/releases"
target="_blank"
rel="noopener noreferrer"
aria-label={updateLabel}
className="inline-flex size-4 items-center justify-center rounded text-brand-500 hover:bg-brand-500/15 focus-visible:outline-2 focus-visible:outline-brand-500"
>
<ArrowUp className="size-3.5" aria-hidden="true" />
</a>
</Tooltip>
) : null}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions web/src/layout/Components/ApplicationShell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -61,6 +62,7 @@ export default function AuthenticatedShell({
onLogout={onLogout}
user={user}
/>
<ApplicationVersion />
</div>
</aside>

Expand Down
67 changes: 67 additions & 0 deletions web/src/server/Updates/releases.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> | 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<string | null> {
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)
}
36 changes: 36 additions & 0 deletions web/src/tests/application-version.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
8 changes: 8 additions & 0 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import { builtinModules } from 'node:module'
import { fileURLToPath } from 'node:url'

Expand All @@ -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,
Expand Down
Loading