feat: implement version UI and short-sha CI versioning

This commit is contained in:
neon
2026-05-13 21:59:15 +00:00
parent 4fb157cc3b
commit f9c1e29911
25 changed files with 1107 additions and 63 deletions
+16 -8
View File
@@ -82,6 +82,7 @@ jobs:
packages: read
outputs:
source_sha: ${{ steps.ctx.outputs.source_sha }}
source_sha_short: ${{ steps.ctx.outputs.source_sha_short }}
update_main: ${{ steps.ctx.outputs.update_main }}
skip_build: ${{ steps.ctx.outputs.skip_build }}
steps:
@@ -108,14 +109,20 @@ jobs:
SOURCE_SHA="$GH_SHA"
echo "update_main=false" >> "$GITHUB_OUTPUT"
fi
SOURCE_SHA_SHORT="${SOURCE_SHA:0:12}"
echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
echo "source_sha_short=${SOURCE_SHA_SHORT}" >> "$GITHUB_OUTPUT"
IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
HTTP_STATUS_SHORT=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA_SHORT}")
HTTP_STATUS_FULL=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}")
if [[ "$HTTP_STATUS" == "200" && "${FORCE_REBUILD}" != "true" ]]; then
echo "Image sha-${SOURCE_SHA} already exists in GHCR, skipping rebuild."
if [[ ("$HTTP_STATUS_SHORT" == "200" || "$HTTP_STATUS_FULL" == "200") && "${FORCE_REBUILD}" != "true" ]]; then
echo "Image sha-${SOURCE_SHA_SHORT} already exists in GHCR, skipping rebuild."
echo "skip_build=true" >> "$GITHUB_OUTPUT"
else
echo "skip_build=false" >> "$GITHUB_OUTPUT"
@@ -159,6 +166,7 @@ jobs:
file: ./Dockerfile
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }}
cache-from: type=gha,scope=container-image-yml-amd64
cache-to: type=gha,mode=max,scope=container-image-yml-amd64
@@ -212,6 +220,7 @@ jobs:
file: ./Dockerfile
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }}
cache-from: type=gha,scope=container-image-yml-arm64
cache-to: type=gha,mode=max,scope=container-image-yml-arm64
@@ -255,13 +264,13 @@ jobs:
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
run: |
FULL_IMAGE="${REGISTRY}/${IMAGE}"
docker buildx imagetools create \
--tag "${FULL_IMAGE}:sha-${SHA}" \
--tag "${FULL_IMAGE}:sha-${SHA_SHORT}" \
"${FULL_IMAGE}@${AMD64_DIGEST}" \
"${FULL_IMAGE}@${ARM64_DIGEST}"
@@ -271,7 +280,6 @@ jobs:
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
run: |
@@ -286,5 +294,5 @@ jobs:
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA}"
SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }}
run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA_SHORT}"
+33 -48
View File
@@ -142,12 +142,13 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
run: |
set -euo pipefail
SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${COMMIT_SHA}"
SHORT_SHA="${COMMIT_SHA:0:12}"
SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${SHORT_SHA}"
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
echo "::error::Multi-arch image sha-${COMMIT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
echo "::error::Multi-arch image sha-${SHORT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
exit 1
fi
echo "Image sha-${COMMIT_SHA} verified in GHCR."
echo "Image sha-${SHORT_SHA} verified in GHCR."
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
@@ -192,7 +193,7 @@ jobs:
needs: [release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 60
permissions:
contents: write
packages: write
@@ -200,6 +201,14 @@ jobs:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout release tag
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.release.outputs.tag }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@@ -210,46 +219,21 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify SHA image and resolve digest
id: source
shell: bash
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ github.sha }}
run: |
FULL_IMAGE="${REGISTRY}/${IMAGE}"
SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}"
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
exit 1
fi
DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${SOURCE_TAG}" | jq -r '.digest')
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Promote to version and latest
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
DIGEST: ${{ steps.source.outputs.digest }}
IMAGE: ${{ steps.source.outputs.image }}
run: |
docker buildx imagetools create \
--tag "${IMAGE}:${VERSION}" \
"${IMAGE}@${DIGEST}"
docker buildx imagetools create \
--tag "${IMAGE}:latest" \
"${IMAGE}@${DIGEST}"
- name: Inspect promoted manifests
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
IMAGE: ${{ steps.source.outputs.image }}
run: |
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
docker buildx imagetools inspect "${IMAGE}:latest"
- name: Build and push versioned image
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
build-args: APP_VERSION=v${{ needs.release.outputs.version }}
cache-from: |
type=gha,scope=container-image-yml-amd64
type=gha,scope=container-image-yml-arm64
- name: Enrich release notes
shell: bash
@@ -259,8 +243,8 @@ jobs:
TAG: ${{ needs.release.outputs.tag }}
VERSION: ${{ needs.release.outputs.version }}
SHA: ${{ github.sha }}
IMAGE: ${{ steps.source.outputs.image }}
DIGEST: ${{ steps.source.outputs.digest }}
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
BODY_FILE="$(mktemp)"
@@ -300,7 +284,8 @@ jobs:
VERSION: ${{ needs.release.outputs.version }}
SHA: ${{ github.sha }}
run: |
echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY"
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Source:** \`sha-${SHA}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Version:** \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY"
echo "- **APP_VERSION baked:** \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
+3
View File
@@ -40,6 +40,9 @@ RUN mkdir -p /deploy/migrations && cp -r /app/server/src/db/migrations/. /deploy
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
RUN apk upgrade --no-cache && \
apk add --no-cache poppler-utils su-exec ffmpeg && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
+34 -6
View File
@@ -29,8 +29,9 @@ import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmar
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
import { useInstallPrompt } from '@/features/pwa/composables/useInstallPrompt'
import { useThemeStore } from '@/stores/theme'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
@@ -56,9 +57,12 @@ const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const { handleLibraryCreated } = useLibraryCreationRedirect()
const themeStore = useThemeStore()
const { isInstallable, installApp } = useInstallPrompt()
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const versionUi = computed(() => {
return buildSidebarVersionUi(version.value, updateAvailable.value, latestVersion.value)
})
const refreshedFor = new Set<number>()
watch(progressMap, (map) => {
@@ -147,6 +151,7 @@ onMounted(async () => {
}
fetchSmartScopes()
fetchCollections()
loadAppInfo()
})
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
@@ -433,10 +438,33 @@ onUnmounted(() => stopLibraryUploadListener())
</SidebarGroup>
</SidebarContent>
<SidebarFooter v-if="isInstallable" class="border-t border-sidebar-border/60 pb-2">
<SidebarMenu>
<SidebarNavItem :is-active="false" tooltip="Install App" :icon="Icons.Download" label="Install App" @click="installApp" />
</SidebarMenu>
<SidebarFooter v-if="versionUi.currentLabel" class="border-t border-sidebar-border/60">
<div class="px-2 py-2 group-data-[collapsible=icon]:hidden">
<div class="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
<a
v-if="versionUi.currentHref"
:href="versionUi.currentHref"
target="_blank"
rel="noopener noreferrer"
class="transition-colors hover:text-sidebar-foreground"
>
{{ versionUi.currentLabel }}
</a>
<span v-else>{{ versionUi.currentLabel }}</span>
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
<a
v-if="versionUi.showLatest"
:href="versionUi.latestHref"
target="_blank"
rel="noopener noreferrer"
class="font-semibold text-primary transition-colors hover:text-primary/85"
>
Latest {{ versionUi.latestLabel }}
</a>
</div>
</div>
</SidebarFooter>
<SidebarRail />
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { buildSidebarVersionUi } from '../versionUi'
describe('buildSidebarVersionUi', () => {
it('builds a single current-tag link when no update is available', () => {
const ui = buildSidebarVersionUi('v1.2.3', false, 'v1.3.0')
expect(ui).toEqual({
currentLabel: 'v1.2.3',
currentHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3',
showLatest: false,
latestLabel: 'v1.3.0',
latestHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.3.0',
})
})
it('shows both current and latest links when an update is available', () => {
const ui = buildSidebarVersionUi('v1.2.3', true, 'v1.4.0')
expect(ui.currentLabel).toBe('Current v1.2.3')
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3')
expect(ui.showLatest).toBe(true)
expect(ui.latestLabel).toBe('v1.4.0')
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.4.0')
})
it('does not show latest when latestVersion is null', () => {
const ui = buildSidebarVersionUi('v1.2.3', true, null)
expect(ui.showLatest).toBe(false)
expect(ui.currentLabel).toBe('v1.2.3')
})
it('does not show latest when latestVersion is blank after trimming', () => {
const ui = buildSidebarVersionUi('v1.2.3', true, ' ')
expect(ui.showLatest).toBe(false)
expect(ui.currentLabel).toBe('v1.2.3')
expect(ui.latestLabel).toBe('')
})
it('shows local build labels as-is', () => {
const ui = buildSidebarVersionUi('Local build', null, null)
expect(ui.currentLabel).toBe('Local build')
expect(ui.currentHref).toBeNull()
})
it('trims version strings before building labels and URLs', () => {
const ui = buildSidebarVersionUi(' v2.0.1 ', true, ' v2.1.0 ')
expect(ui.currentLabel).toBe('Current v2.0.1')
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.0.1')
expect(ui.latestLabel).toBe('v2.1.0')
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.1.0')
})
it('falls back latest link to /releases/latest for non-tag latest versions', () => {
const ui = buildSidebarVersionUi('v1.2.3', true, 'main-abc123')
expect(ui.showLatest).toBe(true)
expect(ui.latestLabel).toBe('main-abc123')
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/latest')
})
it('links sha versions to commits and does not enable latest mode', () => {
const ui = buildSidebarVersionUi('sha-abc1234', true, 'v1.2.4')
expect(ui.currentLabel).toBe('sha-abc1234')
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/abc1234')
expect(ui.showLatest).toBe(false)
})
it('does not enable latest mode for local builds', () => {
const ui = buildSidebarVersionUi('Local build', true, 'v1.2.4')
expect(ui.currentLabel).toBe('Local build')
expect(ui.showLatest).toBe(false)
})
it('shortens long sha labels to 12 chars for readability', () => {
const ui = buildSidebarVersionUi('sha-1234567890abcdef1234567890abcdef12345678', null, null)
expect(ui.currentLabel).toBe('sha-1234567890ab')
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890abcdef1234567890abcdef12345678')
})
it('links short sha versions to the matching commit page', () => {
const ui = buildSidebarVersionUi('sha-1234567890ab', null, null)
expect(ui.currentLabel).toBe('sha-1234567890ab')
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890ab')
})
it('does not prefix current label when updateAvailable is null', () => {
const ui = buildSidebarVersionUi('v1.2.3', null, 'v1.3.0')
expect(ui.currentLabel).toBe('v1.2.3')
expect(ui.showLatest).toBe(false)
})
it('suppresses latest presentation when current version is blank', () => {
const ui = buildSidebarVersionUi(' ', true, 'v1.3.0')
expect(ui.currentLabel).toBe('')
expect(ui.showLatest).toBe(false)
})
})
@@ -0,0 +1,44 @@
export type SidebarVersionUi = {
currentLabel: string
currentHref: string | null
showLatest: boolean
latestLabel: string
latestHref: string
}
const GITHUB_RELEASES_BASE = 'https://github.com/bookorbit/bookorbit/releases'
const GITHUB_COMMIT_BASE = 'https://github.com/bookorbit/bookorbit/commit'
function isVersionTag(value: string): boolean {
return /^v\d+\.\d+\.\d+$/.test(value)
}
function extractSha(value: string): string | null {
const match = value.match(/^(?:sha-)?([0-9a-f]{7,40})$/i)
return match ? (match[1] ?? null) : null
}
function normalizeVersionLabel(value: string): string {
const normalized = value.trim()
if (!normalized) return ''
const sha = extractSha(normalized)
if (sha) return `sha-${sha.slice(0, 12)}`
return normalized
}
export function buildSidebarVersionUi(version: string, updateAvailable: boolean | null, latestVersion: string | null): SidebarVersionUi {
const currentRaw = version.trim()
const latestRaw = (latestVersion ?? '').trim()
const currentIsTag = isVersionTag(currentRaw)
const currentSha = extractSha(currentRaw)
const currentLabelBase = normalizeVersionLabel(currentRaw)
const showLatest = Boolean(updateAvailable && latestRaw && currentLabelBase && currentIsTag)
return {
currentLabel: showLatest ? `Current ${currentLabelBase}` : currentLabelBase,
currentHref: currentIsTag ? `${GITHUB_RELEASES_BASE}/tag/${currentRaw}` : currentSha ? `${GITHUB_COMMIT_BASE}/${currentSha}` : null,
showLatest,
latestLabel: normalizeVersionLabel(latestRaw),
latestHref: isVersionTag(latestRaw) ? `${GITHUB_RELEASES_BASE}/tag/${latestRaw}` : `${GITHUB_RELEASES_BASE}/latest`,
}
}
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2 } from 'lucide-vue-next'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import MigrationModal from '@/features/migration/components/MigrationModal.vue'
import { api } from '@/lib/api'
@@ -16,6 +17,9 @@ const embeddingError = ref<string | null>(null)
const migrationState = ref<MigrationWorkflowState | null>(null)
const migrationLoading = ref(true)
const updateCheckEnabled = ref(true)
const updateCheckLoading = ref(false)
const migrationSource = computed(() => migrationState.value?.active?.source ?? null)
const migrationRun = computed(() => migrationState.value?.active?.run ?? null)
@@ -34,6 +38,17 @@ onMounted(async () => {
} finally {
migrationLoading.value = false
}
try {
const res = await api('/api/v1/app-settings')
if (res.ok) {
const settings: { key: string; value: string }[] = await res.json()
const row = settings.find((s) => s.key === 'update_check_enabled')
if (row) updateCheckEnabled.value = row.value === 'true'
}
} catch {
// non-fatal
}
})
async function onMigrationModalClose() {
@@ -49,6 +64,27 @@ function goToMigration() {
showMigrationModal.value = true
}
async function toggleUpdateCheck(newVal: boolean) {
updateCheckLoading.value = true
try {
const res = await api('/api/v1/app-settings/update_check_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) {
updateCheckEnabled.value = newVal
toast.success(`Update checks ${newVal ? 'enabled' : 'disabled'}`)
} else {
toast.error('Failed to update setting')
}
} catch {
toast.error('Failed to update setting')
} finally {
updateCheckLoading.value = false
}
}
async function rebuildEmbeddings() {
running.value = true
queued.value = null
@@ -194,5 +230,26 @@ function formatDate(iso: string | null | undefined): string {
</div>
</div>
</div>
<!-- Update checks -->
<div>
<p class="settings-group-label">Updates</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Bell :size="16" class="text-primary" />
</div>
<div class="min-w-0">
<p class="settings-label">Check for updates</p>
<p class="settings-hint">
Automatically check GitHub for a new release on startup and show an indicator in the sidebar when one is available.
</p>
</div>
</div>
<ToggleSwitch :model-value="updateCheckEnabled" :disabled="updateCheckLoading" @update:model-value="toggleUpdateCheck" />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
vi.mock('@/lib/api', () => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
import { api } from '@/lib/api'
import { useAppInfo } from '../useAppInfo'
const mockApi = api as ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
function makeOkResponse(data: object): Response {
return {
ok: true,
status: 200,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(data),
} as unknown as Response
}
function makeErrorResponse(status: number): Response {
return { ok: false, status, json: vi.fn<() => Promise<unknown>>() } as unknown as Response
}
describe('useAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
version.value = ''
updateAvailable.value = null
latestVersion.value = null
isLoading.value = false
error.value = null
})
it('starts in idle state before loadAppInfo is called', () => {
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
expect(version.value).toBe('')
expect(updateAvailable.value).toBeNull()
expect(latestVersion.value).toBeNull()
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
it('resolves with data from API response', async () => {
mockApi.mockResolvedValue(
makeOkResponse({
version: 'v1.2.3',
updateAvailable: false,
latestVersion: 'v1.2.3',
}),
)
const { version, updateAvailable, latestVersion, isLoading, loadAppInfo } = useAppInfo()
const promise = loadAppInfo()
expect(isLoading.value).toBe(true)
await promise
await flushPromises()
expect(version.value).toBe('v1.2.3')
expect(updateAvailable.value).toBe(false)
expect(latestVersion.value).toBe('v1.2.3')
expect(isLoading.value).toBe(false)
})
it('sets updateAvailable: true when a newer version is available', async () => {
mockApi.mockResolvedValue(
makeOkResponse({
version: 'v1.2.3',
updateAvailable: true,
latestVersion: 'v1.3.0',
}),
)
const { updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(updateAvailable.value).toBe(true)
expect(latestVersion.value).toBe('v1.3.0')
})
it('sets updateAvailable: null for local builds', async () => {
mockApi.mockResolvedValue(
makeOkResponse({
version: 'Local build',
updateAvailable: null,
latestVersion: null,
}),
)
const { updateAvailable, version, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(version.value).toBe('Local build')
expect(updateAvailable.value).toBeNull()
})
it('handles API error gracefully - sets error ref, does not throw', async () => {
mockApi.mockRejectedValue(new Error('network failure'))
const { error, version, loadAppInfo } = useAppInfo()
await expect(loadAppInfo()).resolves.not.toThrow()
expect(error.value).toBe('network failure')
expect(version.value).toBe('')
})
it('handles non-ok HTTP response gracefully', async () => {
mockApi.mockResolvedValue(makeErrorResponse(500))
const { error, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(error.value).toMatch(/HTTP 500/)
})
it('sets isLoading to false after error', async () => {
mockApi.mockRejectedValue(new Error('fail'))
const { isLoading, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(isLoading.value).toBe(false)
})
it('does not start a concurrent fetch while one is in progress', async () => {
let resolveFirst!: (value: Response) => void
mockApi.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirst = resolve
}),
)
const { loadAppInfo } = useAppInfo()
const first = loadAppInfo()
const second = loadAppInfo()
resolveFirst(
makeOkResponse({
version: 'v1.0.0',
updateAvailable: false,
latestVersion: 'v1.0.0',
}),
)
await Promise.all([first, second])
expect(mockApi).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,33 @@
import { ref } from 'vue'
import type { AppInfoResponse } from '@bookorbit/types'
import { api } from '@/lib/api'
const version = ref('')
const updateAvailable = ref<boolean | null>(null)
const latestVersion = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
export function useAppInfo() {
async function loadAppInfo() {
if (isLoading.value) return
isLoading.value = true
error.value = null
try {
const res = await api('/api/v1/app-info')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: AppInfoResponse = await res.json()
version.value = data.version
updateAvailable.value = data.updateAvailable
latestVersion.value = data.latestVersion
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load app info'
} finally {
isLoading.value = false
}
}
return { version, updateAvailable, latestVersion, isLoading, error, loadAppInfo }
}
+5
View File
@@ -0,0 +1,5 @@
export interface AppInfoResponse {
version: string;
updateAvailable: boolean | null;
latestVersion: string | null;
}
+1
View File
@@ -28,6 +28,7 @@ export * from "./statistics";
export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
export * from "./app-info";
export * from "./series";
export * from "./table-layout";
export * from "./series-collapse";
+2
View File
@@ -57,6 +57,7 @@ import { SeriesModule } from './modules/series/series.module';
import { EntityManagerModule } from './modules/entity-manager/entity-manager.module';
import { FontModule } from './modules/font/font.module';
import { KoreaderModule } from './modules/koreader/koreader.module';
import { AppInfoModule } from './modules/app-info/app-info.module';
@Module({
imports: [
@@ -122,6 +123,7 @@ import { KoreaderModule } from './modules/koreader/koreader.module';
EntityManagerModule,
FontModule,
KoreaderModule,
AppInfoModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
@@ -20,6 +20,7 @@ export const APP_SETTING_KEYS = {
METADATA_SCORE_WEIGHTS: 'metadata_score_weights',
AUDIT_RETENTION_DAYS: 'audit_retention_days',
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
UPDATE_CHECK_ENABLED: 'update_check_enabled',
} as const;
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
+136
View File
@@ -0,0 +1,136 @@
import { resolve } from 'path';
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, oidcRuntimeConfig, storageConfig } from './config';
const ORIGINAL_ENV = process.env;
function resetEnv(): void {
process.env = { ...ORIGINAL_ENV };
delete process.env.NODE_ENV;
delete process.env.APP_URL;
delete process.env.APP_VERSION;
delete process.env.DATABASE_URL;
delete process.env.JWT_SECRET;
delete process.env.JWT_EXPIRES_IN;
delete process.env.JWT_REFRESH_EXPIRES_IN;
delete process.env.SETUP_BOOTSTRAP_TOKEN;
delete process.env.APP_DATA_PATH;
delete process.env.FILE_WRITE_DEBOUNCE_MS;
delete process.env.FILE_WRITE_MAX_CONCURRENT_WRITES;
delete process.env.EMAIL_ENCRYPTION_KEY;
delete process.env.MIGRATION_ENCRYPTION_KEY;
delete process.env.OIDC_STATE_TTL_SECS;
delete process.env.OIDC_DISCOVERY_CACHE_TTL_SECS;
delete process.env.OIDC_JWKS_CACHE_TTL_SECS;
delete process.env.OIDC_CLOCK_TOLERANCE_SECS;
delete process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS;
}
describe('config', () => {
beforeEach(() => {
resetEnv();
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it('uses app defaults, including local-build fallback version', () => {
expect(appConfig()).toEqual({
nodeEnv: 'development',
appUrl: 'http://localhost:5173',
version: 'Local build',
});
});
it('reads app values from environment when provided', () => {
process.env.NODE_ENV = 'production';
process.env.APP_URL = 'https://bookorbit.local';
process.env.APP_VERSION = 'v2.3.4';
expect(appConfig()).toEqual({
nodeEnv: 'production',
appUrl: 'https://bookorbit.local',
version: 'v2.3.4',
});
});
it('uses defaults for database, auth, email, and migration config', () => {
expect(dbConfig().url).toBe('postgres://bookorbit:bookorbit@localhost:5432/bookorbit');
expect(authConfig()).toEqual({
jwtSecret: 'change-me-in-production',
jwtExpiresIn: '15m',
jwtRefreshExpiresIn: '7d',
setupBootstrapToken: '',
});
expect(emailConfig().encryptionKey).toBe('');
expect(migrationConfig().encryptionKey).toBe('');
});
it('resolves storage path from APP_DATA_PATH', () => {
process.env.APP_DATA_PATH = './tmp/bookorbit-data';
expect(storageConfig().appDataPath).toBe(resolve('./tmp/bookorbit-data'));
});
it('falls back storage path to /data when APP_DATA_PATH is not set', () => {
expect(storageConfig().appDataPath).toBe(resolve('/data'));
});
it('parses positive integers for file-write config and floors decimal values', () => {
process.env.FILE_WRITE_DEBOUNCE_MS = '1234.99';
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '4.2';
expect(fileWriteConfig()).toEqual({
debounceMs: 1234,
maxConcurrentWrites: 4,
});
});
it('uses file-write fallbacks for zero, negatives, NaN, and Infinity', () => {
process.env.FILE_WRITE_DEBOUNCE_MS = '0';
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '-7';
expect(fileWriteConfig()).toEqual({
debounceMs: 3000,
maxConcurrentWrites: 2,
});
process.env.FILE_WRITE_DEBOUNCE_MS = 'nope';
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = 'Infinity';
expect(fileWriteConfig()).toEqual({
debounceMs: 3000,
maxConcurrentWrites: 2,
});
});
it('computes OIDC runtime values from seconds with millisecond conversion', () => {
process.env.OIDC_STATE_TTL_SECS = '11';
process.env.OIDC_DISCOVERY_CACHE_TTL_SECS = '22';
process.env.OIDC_JWKS_CACHE_TTL_SECS = '33';
process.env.OIDC_CLOCK_TOLERANCE_SECS = '44';
process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS = '5555';
expect(oidcRuntimeConfig()).toEqual({
stateTtlMs: 11000,
discoveryCacheTtlMs: 22000,
jwksCacheTtlMs: 33000,
clockToleranceSecs: 44,
tokenExchangeTimeoutMs: 5555,
});
});
it('uses OIDC defaults when values are missing or invalid', () => {
process.env.OIDC_STATE_TTL_SECS = '';
process.env.OIDC_DISCOVERY_CACHE_TTL_SECS = '0';
process.env.OIDC_JWKS_CACHE_TTL_SECS = '-1';
process.env.OIDC_CLOCK_TOLERANCE_SECS = 'abc';
process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS = 'Infinity';
expect(oidcRuntimeConfig()).toEqual({
stateTtlMs: 300000,
discoveryCacheTtlMs: 3600000,
jwksCacheTtlMs: 21600000,
clockToleranceSecs: 30,
tokenExchangeTimeoutMs: 10000,
});
});
});
+1
View File
@@ -4,6 +4,7 @@ import { resolve } from 'path';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
version: process.env.APP_VERSION ?? 'Local build',
}));
export const dbConfig = registerAs('db', () => ({
@@ -0,0 +1,5 @@
export const APP_INFO_ROUTE = 'app-info';
export const GITHUB_RELEASES_API = 'https://api.github.com/repos/bookorbit/bookorbit/releases/latest';
export const GITHUB_RELEASE_URL = 'https://github.com/bookorbit/bookorbit/releases/tag';
export const UPDATE_CHECK_TIMEOUT_MS = 10_000;
export const SEMVER_RE = /^v\d+\.\d+\.\d+/;
@@ -0,0 +1,52 @@
import { RequestMethod } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import type { AppInfoResponse } from '@bookorbit/types';
import { APP_INFO_ROUTE } from './app-info.constants';
import { AppInfoController } from './app-info.controller';
import { AppInfoService } from './app-info.service';
function makeService(): jest.Mocked<AppInfoService> {
return {
getAppInfo: vi.fn(),
onApplicationBootstrap: vi.fn(),
} as unknown as jest.Mocked<AppInfoService>;
}
describe('AppInfoController', () => {
it('keeps the expected route contract for the app-info endpoint', () => {
const classPath = Reflect.getMetadata(PATH_METADATA, AppInfoController);
const methodType = Reflect.getMetadata(METHOD_METADATA, AppInfoController.prototype.getAppInfo);
expect(classPath).toBe(APP_INFO_ROUTE);
expect(methodType).toBe(RequestMethod.GET);
});
describe('getAppInfo', () => {
it('delegates to the service and returns the result', () => {
const service = makeService();
const expected: AppInfoResponse = {
version: 'v1.2.3',
updateAvailable: true,
latestVersion: 'v1.3.0',
};
service.getAppInfo.mockReturnValue(expected);
const controller = new AppInfoController(service);
const result = controller.getAppInfo();
expect(result).toEqual(expected);
expect(service.getAppInfo).toHaveBeenCalledOnce();
});
it('returns updateAvailable: null when service returns null', () => {
const service = makeService();
const expected: AppInfoResponse = { version: 'Local build', updateAvailable: null, latestVersion: null };
service.getAppInfo.mockReturnValue(expected);
const controller = new AppInfoController(service);
expect(controller.getAppInfo()).toEqual(expected);
});
});
});
@@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import type { AppInfoResponse } from '@bookorbit/types';
import { APP_INFO_ROUTE } from './app-info.constants';
import { AppInfoService } from './app-info.service';
@Controller(APP_INFO_ROUTE)
export class AppInfoController {
constructor(private readonly appInfoService: AppInfoService) {}
@Get()
getAppInfo(): AppInfoResponse {
return this.appInfoService.getAppInfo();
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AppInfoController } from './app-info.controller';
import { AppInfoService } from './app-info.service';
@Module({
imports: [AppSettingsModule],
controllers: [AppInfoController],
providers: [AppInfoService],
})
export class AppInfoModule {}
@@ -0,0 +1,256 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { GITHUB_RELEASES_API } from './app-info.constants';
import { AppInfoService } from './app-info.service';
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
return { get: vi.fn().mockReturnValue(version) } as unknown as jest.Mocked<ConfigService>;
}
function makeAppSettings(enabled = true): jest.Mocked<AppSettingsService> {
return {
isUpdateCheckEnabled: vi.fn().mockResolvedValue(enabled),
} as unknown as jest.Mocked<AppSettingsService>;
}
function makeGithubResponse(tagName: string, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue({ tag_name: tagName }),
} as unknown as Response;
}
describe('AppInfoService', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getAppInfo', () => {
it('returns version from config', () => {
const service = new AppInfoService(makeConfig('v2.0.0'), makeAppSettings());
expect(service.getAppInfo().version).toBe('v2.0.0');
});
it('defaults to "Local build" when config returns undefined', () => {
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
const service = new AppInfoService(config, makeAppSettings());
expect(service.getAppInfo().version).toBe('Local build');
});
it('returns null for updateAvailable before bootstrap', () => {
const service = new AppInfoService(makeConfig(), makeAppSettings());
const info = service.getAppInfo();
expect(info.updateAvailable).toBeNull();
expect(info.latestVersion).toBeNull();
});
});
describe('onApplicationBootstrap', () => {
it('fetches GitHub API and sets updateAvailable: true when newer version exists', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
const info = service.getAppInfo();
expect(info.updateAvailable).toBe(true);
expect(info.latestVersion).toBe('v1.3.0');
expect(fetch).toHaveBeenCalledWith(GITHUB_RELEASES_API, expect.objectContaining({ headers: expect.any(Object) }));
});
it('sets updateAvailable: false when already on latest', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.3'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBe(false);
});
it('sets updateAvailable: false when running newer than latest (downgrade scenario)', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.2'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBe(false);
});
it('correctly compares minor version bumps', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.9'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBe(true);
});
it('correctly compares major version bumps', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v2.0.0'));
const service = new AppInfoService(makeConfig('v1.9.9'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBe(true);
});
it('skips fetch and leaves updateAvailable: null when version is "Local build"', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
expect(service.getAppInfo().updateAvailable).toBeNull();
});
it('skips fetch when version is a sha-prefixed string', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('sha-abc1234'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
expect(service.getAppInfo().updateAvailable).toBeNull();
});
it('skips fetch when update checks are disabled', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(false));
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
expect(service.getAppInfo().updateAvailable).toBeNull();
});
it('leaves updateAvailable: null on network error (does not throw)', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null on AbortError timeout (does not throw)', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
vi.spyOn(global, 'fetch').mockRejectedValue(abortError);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null when GitHub API returns non-200', async () => {
const badResponse: Response = {
ok: false,
status: 403,
json: vi.fn(),
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(badResponse);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null when GitHub API returns null body', async () => {
const response: Response = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(null),
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null when tag_name is missing from response', async () => {
const response: Response = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ name: 'Release 1.3.0' }),
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null when tag_name is not a valid semver', async () => {
const response: Response = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ tag_name: 'not-a-version' }),
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('leaves updateAvailable: null when response body is not an object', async () => {
const response: Response = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue('just a string'),
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(service.getAppInfo().updateAvailable).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
});
it('logs start and end on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(logSpy).toHaveBeenCalledTimes(2);
expect(logSpy.mock.calls[0][0]).toContain('[start]');
expect(logSpy.mock.calls[1][0]).toContain('[end]');
});
it('logs warn on failure', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
await service.onApplicationBootstrap();
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('[fail]');
});
});
});
@@ -0,0 +1,116 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { AppInfoResponse } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { GITHUB_RELEASES_API, SEMVER_RE, UPDATE_CHECK_TIMEOUT_MS } from './app-info.constants';
@Injectable()
export class AppInfoService implements OnApplicationBootstrap {
private readonly logger = new Logger(AppInfoService.name);
private updateAvailable: boolean | null = null;
private latestVersion: string | null = null;
constructor(
private readonly config: ConfigService,
private readonly appSettingsService: AppSettingsService,
) {}
async onApplicationBootstrap(): Promise<void> {
const version = this.config.get<string>('app.version') ?? 'Local build';
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
if (!enabled || !SEMVER_RE.test(version)) {
return;
}
await this.checkForUpdate(version);
}
getAppInfo(): AppInfoResponse {
return {
version: this.config.get<string>('app.version') ?? 'Local build',
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
};
}
private async checkForUpdate(currentVersion: string): Promise<void> {
const start = Date.now();
this.logger.log(`[app-info.update_check] [start] version=${currentVersion} - checking for updates`);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(GITHUB_RELEASES_API, {
headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (!response.ok) {
const durationMs = Date.now() - start;
this.logger.warn(
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=HttpError error="GitHub API returned ${response.status}" - update check failed`,
);
return;
}
const data: unknown = await response.json();
const tagName = this.extractTagName(data);
if (!tagName) {
const durationMs = Date.now() - start;
this.logger.warn(
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=ParseError error="unexpected response shape" - update check failed`,
);
return;
}
this.latestVersion = tagName;
this.updateAvailable = this.isNewer(tagName, currentVersion);
const durationMs = Date.now() - start;
this.logger.log(
`[app-info.update_check] [end] version=${currentVersion} latestVersion=${tagName} updateAvailable=${this.updateAvailable} durationMs=${durationMs} - update check completed`,
);
} catch (error) {
const durationMs = Date.now() - start;
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - update check failed`,
);
}
}
private extractTagName(data: unknown): string | null {
if (typeof data !== 'object' || data === null) return null;
const record = data as Record<string, unknown>;
const tagName = record['tag_name'];
if (typeof tagName !== 'string' || !SEMVER_RE.test(tagName)) return null;
return tagName;
}
private isNewer(latestTag: string, currentVersion: string): boolean {
const latest = this.parseSemver(latestTag.replace(/^v/, ''));
const current = this.parseSemver(currentVersion.replace(/^v/, ''));
if (!latest || !current) return false;
if (latest[0] !== current[0]) return latest[0] > current[0];
if (latest[1] !== current[1]) return latest[1] > current[1];
return latest[2] > current[2];
}
private parseSemver(version: string): [number, number, number] | null {
const parts = version.split('.').map(Number);
if (parts.length < 3 || parts.some((p) => isNaN(p))) return null;
return [parts[0], parts[1], parts[2]];
}
}
@@ -10,6 +10,7 @@ function makeService(): jest.Mocked<AppSettingsService> {
getValue: vi.fn(),
update: vi.fn(),
isBookDockAutoFetchEnabled: vi.fn(),
isUpdateCheckEnabled: vi.fn(),
getAuthorsAutoEnrichmentWriteMode: vi.fn(),
isAuthorsProviderAudnexusEnabled: vi.fn(),
getUploadPattern: vi.fn(),
@@ -416,4 +416,21 @@ describe('AppSettingsService', () => {
expect(repo.upsert).toHaveBeenCalledOnce();
});
});
describe('isUpdateCheckEnabled', () => {
it('returns true by default when setting is absent', async () => {
repo.findByKey.mockResolvedValue(undefined);
expect(await service.isUpdateCheckEnabled()).toBe(true);
});
it('returns false when value is "false"', async () => {
repo.findByKey.mockResolvedValue({ key: 'update_check_enabled', value: 'false' } as never);
expect(await service.isUpdateCheckEnabled()).toBe(false);
});
it('returns true when value is "true"', async () => {
repo.findByKey.mockResolvedValue({ key: 'update_check_enabled', value: 'true' } as never);
expect(await service.isUpdateCheckEnabled()).toBe(true);
});
});
});
@@ -212,6 +212,11 @@ export class AppSettingsService {
await this.repo.upsert(APP_SETTING_KEYS.METADATA_SCORE_WEIGHTS, JSON.stringify(weights));
return weights;
}
async isUpdateCheckEnabled(): Promise<boolean> {
const row = await this.repo.findByKey(APP_SETTING_KEYS.UPDATE_CHECK_ENABLED);
return parseBooleanSetting(row?.value, true);
}
}
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
+1
View File
@@ -39,6 +39,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
{ key: APP_SETTING_KEYS.AUTHORS_PROVIDER_AUDNEXUS_ENABLED, value: 'true' },
{ key: APP_SETTING_KEYS.AUDIT_RETENTION_DAYS, value: String(DEFAULT_AUDIT_RETENTION_DAYS) },
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
];
@Injectable()