From 7a991a50c71809cf427f4e5719ce873b243df15c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 27 Oct 2026 15:32:16 +0100 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20sichere=20Brotli-Laufzeit=20ab=20und?= =?UTF-8?q?=20erh=C3=B6he=20Patchversionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .nvmrc | 2 +- nuxt.config.js | 1 + package-lock.json | 6 +++--- package.json | 4 ++-- scripts/require-safe-node.cjs | 22 ++++++++++++++++++++++ server/plugins/require-safe-node.js | 6 ++++++ 6 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 scripts/require-safe-node.cjs create mode 100644 server/plugins/require-safe-node.js diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..ce62013 100755 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24.22.1 diff --git a/nuxt.config.js b/nuxt.config.js index 925f811..03eb1e5 100755 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -1,3 +1,4 @@ +import './scripts/require-safe-node.cjs'; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: process.env.NODE_ENV !== 'production' }, diff --git a/package-lock.json b/package-lock.json index 2a73eb6..ffc18d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "harheimertc-website", - "version": "1.8.13", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "harheimertc-website", - "version": "1.8.13", + "version": "1.9.1", "hasInstallScript": true, "dependencies": { "@pinia/nuxt": "^0.11.2", @@ -44,7 +44,7 @@ "vue-eslint-parser": "^10.2.0" }, "engines": { - "node": ">=22.12.0", + "node": "^22.23.4 || ^24.22.1 || ^26.12.1", "npm": "10.9.7" } }, diff --git a/package.json b/package.json index 7cf38d3..b6e925c 100755 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "harheimertc-website", - "version": "1.9.0", + "version": "1.9.1", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", "engines": { - "node": ">=22.12.0", + "node": "^22.23.4 || ^24.22.1 || ^26.12.1", "npm": "10.9.7" }, "scripts": { diff --git a/scripts/require-safe-node.cjs b/scripts/require-safe-node.cjs new file mode 100644 index 0000000..b5c7311 --- /dev/null +++ b/scripts/require-safe-node.cjs @@ -0,0 +1,22 @@ +// CVE-2026-111497: Node's bundled Brotli decoder must include the security fix. +// Check Node versions: patched releases can still report Brotli 1.2.0. +// https://nodejs.org/en/blog/vulnerability/october-2026-security-releases +'use strict'; + +function requireSafeNode() { + const [major, minor, patch] = process.versions.node.split('.').map(Number); + const minimum = { 22: [23, 4], 24: [22, 1], 26: [12, 1] }[major]; + const patched = minimum && (minor > minimum[0] || + (minor === minimum[0] && patch >= minimum[1])); + + if (!patched) { + throw new Error( + `CVE-2026-111497: Node.js ${process.versions.node} is not an approved patched runtime. ` + + 'Use Node.js 22.23.4+, 24.22.1+ or 26.12.1+ within those release lines. ' + + 'Recommended: nvm install && nvm use (see .nvmrc). Restart all server/PM2 processes after upgrading.' + ); + } +} + +requireSafeNode(); +module.exports = requireSafeNode; diff --git a/server/plugins/require-safe-node.js b/server/plugins/require-safe-node.js new file mode 100644 index 0000000..85eef7c --- /dev/null +++ b/server/plugins/require-safe-node.js @@ -0,0 +1,6 @@ +import requireSafeNode from '../../scripts/require-safe-node.cjs'; + +// An explicit call keeps the guard in Nitro's production bundle. +requireSafeNode(); + +export default defineNitroPlugin(() => {}); -- 2.34.1 From 4ecc2cc90cc27de7e26e934154f927f5a02aa92a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 29 Oct 2026 13:52:05 +0100 Subject: [PATCH 2/5] fix: animiere Live-Ticker und stabilisiere Benachrichtigungsschalter --- .../NotificationPreferencesRepository.kt | 12 +- .../ui/components/LiveTickerBanner.kt | 111 +++++++++++++---- .../NotificationSettingsScreen.kt | 5 +- .../NotificationSettingsViewModel.kt | 17 ++- .../ui/screens/login/LoginViewModelTest.kt | 4 +- .../NotificationSettingsViewModelTest.kt | 96 +++++++++++++++ android-app/gradle.properties | 4 +- components/LiveTickerBanner.vue | 112 +++++++++++++++--- tests/config-profile-endpoints.spec.ts | 15 +++ 9 files changed, 325 insertions(+), 51 deletions(-) create mode 100644 android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt index 9656b2e..22d4b18 100755 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor( } suspend fun saveRemote(settings: NotificationPreferences): Result { - saveLocal(settings) return runCatching { retryOnNetworkFailure { val response = api.updateNotificationSettings(settings.toDto()) if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.") val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort") - saveLocal(saved) + requireLiveNotificationSettingsSaved(settings, saved) saved } } @@ -120,6 +119,15 @@ class NotificationPreferencesRepository @Inject constructor( } } +internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) { + if (requested.liveTickerStarts != saved.liveTickerStarts || + requested.liveTickerScoreChanges != saved.liveTickerScoreChanges || + requested.liveTickerFinished != saved.liveTickerFinished + ) { + error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.") + } +} + private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences( newNews = newNews, newEvents = newEvents, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt index 0243fe7..b6d2ec2 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt @@ -1,25 +1,40 @@ package de.harheimertc.ui.components -import androidx.compose.foundation.horizontalScroll +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.harheimertc.data.LiveTickerMatchDto +import kotlinx.coroutines.isActive @Composable fun LiveTickerBanner(matches: List, loaded: Boolean) { @@ -45,34 +60,78 @@ fun LiveTickerBanner(matches: List, loaded: Boolean) { if (matches.isEmpty()) return + var paused by remember { mutableStateOf(false) } Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 9.dp), - ) { - matches.forEachIndexed { index, match -> - if (index > 0) { - Text(" • ", color = Color(0xFF64748B)) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { paused = !paused }, + modifier = Modifier.semantics { + contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten" + }, + ) { + Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155)) + } + BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) { + val viewportWidth = maxWidth + var groupWidth by remember { mutableStateOf(0) } + val offset = remember { Animatable(0f) } + val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() } + LaunchedEffect(groupWidth, pixelsPerSecond, paused) { + if (groupWidth <= 0 || paused) return@LaunchedEffect + offset.snapTo(offset.value % groupWidth) + while (isActive) { + val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1) + offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing)) + offset.snapTo(0f) + // Also yield when system animations are disabled. + withFrameNanos { } + } } - val status = when { - match.completed -> "✓ beendet" - match.hasLiveData -> "● LIVE" - else -> "◌ Keine Live-Daten" + Row( + modifier = Modifier + .wrapContentWidth(Alignment.Start, unbounded = true) + .graphicsLayer { translationX = -offset.value }, + ) { + repeat(2) { copy -> + LiveTickerGroup( + matches = matches, + modifier = Modifier + .widthIn(min = viewportWidth) + .then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}), + ) + } } - Text( - text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}", - color = when { - match.completed -> Color(0xFF64748B) - match.hasLiveData -> Color(0xFFB91C1C) - else -> Color(0xFF334155) - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold, - fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal, - maxLines = 1, - ) } } } } + +@Composable +private fun LiveTickerGroup(matches: List, modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 9.dp), + ) { + matches.forEachIndexed { index, match -> + if (index > 0) { + Text(" • ", color = Color(0xFF64748B)) + } + val status = when { + match.completed -> "✓ beendet" + match.hasLiveData -> "● LIVE" + else -> "◌ Keine Live-Daten" + } + Text( + text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}", + color = when { + match.completed -> Color(0xFF64748B) + match.hasLiveData -> Color(0xFFB91C1C) + else -> Color(0xFF334155) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold, + fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal, + maxLines = 1, + ) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt index f4a3f99..4942a5f 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -210,7 +210,10 @@ fun NotificationSettingsScreen( } state.saveError?.let { message -> - item { Text(message, color = MaterialTheme.colorScheme.error) } + item { + Text(message, color = MaterialTheme.colorScheme.error) + TextButton(onClick = { viewModel.update(state.settings) }) { Text("Erneut speichern") } + } } state.error?.let { message -> diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt index 906a22f..f91a802 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -12,6 +12,8 @@ import de.harheimertc.repositories.PushTokenRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject data class NotificationSettingsUiState( @@ -37,6 +39,8 @@ class NotificationSettingsViewModel @Inject constructor( ) : ViewModel() { private val _state = MutableStateFlow(NotificationSettingsUiState()) val state: StateFlow = _state + private val saveMutex = Mutex() + private var settingsRevision = 0L init { load() @@ -66,14 +70,21 @@ class NotificationSettingsViewModel @Inject constructor( } fun update(settings: NotificationPreferences) { + val revision = ++settingsRevision preferencesRepository.saveLocal(settings) _state.value = _state.value.copy(settings = settings, saveError = null) viewModelScope.launch { - preferencesRepository.saveRemote(settings) - .onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) } - .onFailure { error -> + saveMutex.withLock { + if (revision != settingsRevision) return@withLock + val result = preferencesRepository.saveRemote(settings) + if (revision != settingsRevision) return@withLock + result.onSuccess { saved -> + preferencesRepository.saveLocal(saved) + _state.value = _state.value.copy(settings = saved, saveError = null) + }.onFailure { error -> _state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.") } + } } } diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt index a2412e6..536e5ae 100755 --- a/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt @@ -38,7 +38,7 @@ class LoginViewModelTest { val repo = mockk() val passkeyRepo = mockk() coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) - val vm = LoginViewModel(repo, passkeyRepo) + val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true)) vm.setEmail("invalid-email") vm.setPassword("") @@ -57,7 +57,7 @@ class LoginViewModelTest { val passkeyRepo = mockk() coEvery { repo.login(any(), any()) } returns Result.success(loginResp) coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) - val vm = LoginViewModel(repo, passkeyRepo) + val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true)) vm.setEmail("max@ex.de") vm.setPassword("secret") diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt new file mode 100644 index 0000000..aca748f --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModelTest.kt @@ -0,0 +1,96 @@ +package de.harheimertc.ui.screens.notifications + +import de.harheimertc.data.AuthStatusResponse +import de.harheimertc.repositories.LoginRepository +import de.harheimertc.repositories.MannschaftenRepository +import de.harheimertc.repositories.NotificationPreferences +import de.harheimertc.repositories.NotificationPreferencesRepository +import de.harheimertc.repositories.PushTokenRepository +import de.harheimertc.repositories.requireLiveNotificationSettingsSaved +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationSettingsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val preferences = mockk(relaxed = true) + private val teams = mockk() + private val login = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { preferences.loadLocal() } returns NotificationPreferences() + coEvery { preferences.loadRemote() } returns Result.success(NotificationPreferences()) + coEvery { login.status() } returns Result.success(AuthStatusResponse()) + coEvery { teams.fetchSeasons() } returns Result.failure(IllegalStateException("offline")) + coEvery { teams.fetchMannschaften(any()) } returns Result.success(emptyList()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = NotificationSettingsViewModel(preferences, teams, login, mockk()) + + @Test + fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) { + val firstResponse = CompletableDeferred>() + val first = NotificationPreferences(liveTickerStarts = true) + val latest = first.copy(liveTickerScoreChanges = true, liveTickerFinished = true) + coEvery { preferences.saveRemote(first) } coAnswers { firstResponse.await() } + coEvery { preferences.saveRemote(latest) } returns Result.success(latest) + val vm = viewModel() + runCurrent() + vm.update(first) + runCurrent() + vm.update(latest) + runCurrent() + firstResponse.complete(Result.success(first)) + runCurrent() + assertEquals(latest, vm.state.value.settings) + verify(exactly = 1) { preferences.saveLocal(first) } + verify(atLeast = 1) { preferences.saveLocal(latest) } + } + + @Test + fun rejectedLiveSettingsStayVisibleWithSaveError() = runTest(dispatcher) { + val requested = NotificationPreferences(liveTickerFinished = true) + coEvery { preferences.saveRemote(requested) } returns Result.failure(IllegalStateException("Backend aktualisieren")) + val vm = viewModel() + runCurrent() + vm.update(requested) + runCurrent() + assertTrue(vm.state.value.settings.liveTickerFinished) + assertEquals("Backend aktualisieren", vm.state.value.saveError) + } + + @Test + fun serverMustConfirmEachLivePreference() { + val defaults = NotificationPreferences() + for (requested in listOf( + defaults.copy(liveTickerStarts = true), + defaults.copy(liveTickerScoreChanges = true), + defaults.copy(liveTickerFinished = true), + )) { + val error = runCatching { requireLiveNotificationSettingsSaved(requested, defaults) }.exceptionOrNull() + assertTrue(error?.message.orEmpty().contains("Server")) + requireLiveNotificationSettingsSaved(requested, requested) + } + } +} diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 9b70bac..f29737d 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=38 -ANDROID_VERSION_NAME=0.11.0 +ANDROID_VERSION_CODE=39 +ANDROID_VERSION_NAME=0.11.1 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/components/LiveTickerBanner.vue b/components/LiveTickerBanner.vue index d4ba933..c9b62bb 100644 --- a/components/LiveTickerBanner.vue +++ b/components/LiveTickerBanner.vue @@ -3,7 +3,7 @@ v-if="isInternal && matches.length" class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm" > -
+
-
- ✓ beendet - ● LIVE - ◌ Keine Live-Daten - {{ match.homeTeam }} {{ match.score }} {{ match.awayTeam }} + {{ paused ? '▶' : 'Ⅱ' }} + +
+
+
+
+ ✓ beendet + ● LIVE + ◌ Keine Live-Daten + {{ match.homeTeam }} {{ match.score }} {{ match.awayTeam }} +
+
+
@@ -41,12 +68,26 @@ const route = useRoute() const authStore = useAuthStore() const matches = ref([]) const soundEnabled = ref(false) +const paused = ref(false) +const tickerTrack = ref(null) +const tickerDuration = ref(30) const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms'))) let timer let audioContext let tickerLoaded = false let previousStates = new Map() +// Two equal copies let the track wrap without a visible jump. ResizeObserver +// keeps the speed at 36 px/s when results, fonts or the viewport change. +watch(tickerTrack, (track, _previous, onCleanup) => { + if (!track) return + const observer = new ResizeObserver(() => { + tickerDuration.value = Math.max(1, track.getBoundingClientRect().width / 2 / 36) + }) + observer.observe(track) + onCleanup(() => observer.disconnect()) +}, { flush: 'post' }) + function stateByMeeting(items) { return new Map(items.map(match => [ match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`, @@ -109,3 +150,44 @@ onMounted(() => { onUnmounted(() => clearInterval(timer)) watch(isInternal, load) + + diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts index d0d2446..ec3c6b7 100755 --- a/tests/config-profile-endpoints.spec.ts +++ b/tests/config-profile-endpoints.spec.ts @@ -175,6 +175,9 @@ describe('Config & Profil Endpoints', () => { id: '1', notificationSettings: { eventsToday: true, + liveTickerStarts: true, + liveTickerScoreChanges: true, + liveTickerFinished: true, selectedTeamSlugs: ['herren-1', 'herren-1', ''], selectedTeamSeason: '2025/2026', notificationTime: '07:30' @@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => { expect(result.success).toBe(true) expect(result.settings.eventsToday).toBe(true) + expect(result.settings.liveTickerStarts).toBe(true) + expect(result.settings.liveTickerScoreChanges).toBe(true) + expect(result.settings.liveTickerFinished).toBe(true) expect(result.settings.newEvents).toBe(false) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1']) expect(result.settings.notificationTime).toBe('07:30') @@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) mockSuccessReadBody({ newEvents: true, + liveTickerStarts: true, + liveTickerScoreChanges: true, + liveTickerFinished: true, eventsToday: 'true', birthdays: true, selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '], @@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => { expect(result.success).toBe(true) expect(result.settings.newEvents).toBe(true) + expect(result.settings.liveTickerStarts).toBe(true) + expect(result.settings.liveTickerScoreChanges).toBe(true) + expect(result.settings.liveTickerFinished).toBe(true) expect(result.settings.eventsToday).toBe(false) expect(result.settings.birthdays).toBe(true) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend']) @@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => { id: '1', notificationSettings: expect.objectContaining({ newEvents: true, + liveTickerStarts: true, + liveTickerScoreChanges: true, + liveTickerFinished: true, birthdays: true, selectedTeamSeason: '2026/2027' }) -- 2.34.1 From 10a37180dedac74fa280687c18835339f155c210 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 29 Oct 2026 15:48:17 +0100 Subject: [PATCH 3/5] Refactor deployment scripts to improve Node.js version handling and PM2 process management - Updated `ensure_node_version` function in `deploy-production.sh` and `deploy-test.sh` to use a common script for Node.js version validation. - Simplified PM2 instance restart logic by introducing `deploy_restart_pm2` function in a new `deploy-runtime.sh` script. - Replaced direct PM2 commands with the new function to handle instance restarts and HTTP readiness checks. - Modified PM2 configuration files to set the script directly instead of using `node` as the interpreter, allowing for better compatibility with older Node.js versions. - Added tests for the new deployment runtime functions in `deploy-runtime.spec.ts` to ensure correct behavior during deployment. - Updated `package-lock.json` to include new dependencies and ensure compatibility with the latest changes. --- deploy-production.sh | 48 +-- deploy-test.sh | 63 +--- harheimertc.config.cjs | 5 +- harheimertc.test.config.cjs | 5 +- package-lock.json | 569 ++++++++++++++++++++++++++++++++++- scripts/deploy-runtime.sh | 45 +++ tests/deploy-runtime.spec.ts | 58 ++++ 7 files changed, 693 insertions(+), 100 deletions(-) create mode 100644 scripts/deploy-runtime.sh create mode 100644 tests/deploy-runtime.spec.ts diff --git a/deploy-production.sh b/deploy-production.sh index 60ebdcc..15b80ac 100755 --- a/deploy-production.sh +++ b/deploy-production.sh @@ -137,13 +137,8 @@ ensure_node_version() { local node_version node_version="$(node -p 'process.versions.node')" - if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then - echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv." - echo "Bitte Node 22 installieren/aktivieren, z.B.:" - echo " nvm install 22" - echo " nvm alias default 22" - exit 1 - fi + # Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server. + node "$SCRIPT_DIR/scripts/require-safe-node.cjs" echo " Node.js $node_version" } @@ -544,29 +539,11 @@ if ! command -v pm2 &> /dev/null; then exit 1 fi -# Funktion zum Starten/Neustarten einer PM2-Instanz +# Gemeinsamer Start mit der ausgewählten Node-Laufzeit und HTTP-Prüfung. +# shellcheck source=scripts/deploy-runtime.sh +source "$SCRIPT_DIR/scripts/deploy-runtime.sh" restart_pm2_instance() { - local instance_name=$1 - if ! pm2 describe "$instance_name" &> /dev/null; then - echo " WARNING: PM2-Prozess '$instance_name' existiert nicht." - echo " Versuche, den Prozess zu starten..." - if pm2 start harheimertc.config.cjs --only "$instance_name" --update-env; then - echo " ✓ PM2-Prozess '$instance_name' gestartet" - return 0 - else - echo " ERROR: Konnte PM2-Prozess '$instance_name' nicht starten." - return 1 - fi - else - echo " Restarting $instance_name with --update-env..." - if pm2 restart "$instance_name" --update-env; then - echo " ✓ PM2-Prozess '$instance_name' neu gestartet" - return 0 - else - echo " ERROR: PM2-Restart für '$instance_name' fehlgeschlagen!" - return 1 - fi - fi + deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.config.cjs" } # Starte/Neustarte die Produktionsinstanz @@ -576,23 +553,16 @@ if ! restart_pm2_instance "harheimertc"; then INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) fi -# Prüfe, ob der Prozess läuft -sleep 2 -echo "" -echo " Checking PM2 instances status..." - -if pm2 describe harheimertc | grep -q "online"; then - echo " ✓ PM2-Prozess 'harheimertc' läuft (online)" -else - echo " WARNING: PM2-Prozess 'harheimertc' ist nicht online. Prüfe Logs: pm2 logs harheimertc" +if ! deploy_wait_for_http "harheimertc" "http://127.0.0.1:3100/"; then INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1)) fi if [ "$INSTANCE_ERRORS" -gt 0 ]; then echo "" - echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:" + echo "ERROR: Produktionsinstanz konnte nicht erfolgreich gestartet werden. Bitte manuell prüfen:" echo " pm2 status" echo " pm2 logs harheimertc" + exit 1 fi echo "" diff --git a/deploy-test.sh b/deploy-test.sh index bd7f3df..cf84c4f 100755 --- a/deploy-test.sh +++ b/deploy-test.sh @@ -150,13 +150,8 @@ ensure_node_version() { local node_version node_version="$(node -p 'process.versions.node')" - if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then - echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv." - echo "Bitte Node 22 installieren/aktivieren, z.B.:" - echo " nvm install 22" - echo " nvm alias default 22" - exit 1 - fi + # Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server. + node "$SCRIPT_DIR/scripts/require-safe-node.cjs" echo " Node.js $node_version" } @@ -545,39 +540,11 @@ if ! command -v pm2 &> /dev/null; then exit 1 fi -# Funktion zum Starten/Neustarten einer PM2-Instanz +# Gemeinsamer Start mit der ausgewählten Node-Laufzeit und HTTP-Prüfung. +# shellcheck source=scripts/deploy-runtime.sh +source "$SCRIPT_DIR/scripts/deploy-runtime.sh" restart_pm2_instance() { - local instance_name=$1 - local config_file="$SCRIPT_DIR/harheimertc.test.config.cjs" - - # Prüfe, ob die Konfigurationsdatei existiert - if [ ! -f "$config_file" ]; then - echo " ERROR: PM2-Konfigurationsdatei nicht gefunden: $config_file" - return 1 - fi - - if ! pm2 describe "$instance_name" &> /dev/null; then - echo " WARNING: PM2-Prozess '$instance_name' existiert nicht." - echo " Versuche, den Prozess zu starten..." - echo " Verwende Konfiguration: $config_file" - if pm2 start "$config_file" --update-env; then - echo " ✓ PM2-Prozess '$instance_name' gestartet" - return 0 - else - echo " ERROR: Konnte PM2-Prozess '$instance_name' nicht starten." - echo " Bitte manuell prüfen: pm2 logs $instance_name" - return 1 - fi - else - echo " Restarting $instance_name with --update-env..." - if pm2 restart "$instance_name" --update-env; then - echo " ✓ PM2-Prozess '$instance_name' neu gestartet" - return 0 - else - echo " ERROR: PM2-Restart für '$instance_name' fehlgeschlagen!" - return 1 - fi - fi + deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.test.config.cjs" } # Starte/Neustarte Test-Instanz @@ -589,23 +556,7 @@ if ! restart_pm2_instance "harheimertc.test"; then exit 1 fi -# Prüfe, ob der Prozess läuft -sleep 2 -echo "" -echo " Checking PM2 instance status..." - -if pm2 describe harheimertc.test | grep -q "online"; then - echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)" -else - echo " ERROR: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test" - exit 1 -fi - -if ! curl --fail --silent --show-error --max-time 15 http://127.0.0.1:3102/ >/dev/null; then - echo "ERROR: Test-Instanz antwortet nicht auf Port 3102." - exit 1 -fi -echo " ✓ HTTP-Healthcheck auf Port 3102 erfolgreich" +deploy_wait_for_http "harheimertc.test" "http://127.0.0.1:3102/" echo "" echo "=== Test-Instanz Deployment completed successfully! ===" diff --git a/harheimertc.config.cjs b/harheimertc.config.cjs index b3dbd48..13c5be4 100755 --- a/harheimertc.config.cjs +++ b/harheimertc.config.cjs @@ -49,8 +49,9 @@ module.exports = { { name: 'harheimertc', // Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode") - script: 'node', - args: '.output/server/index.mjs', + script: '.output/server/index.mjs', + // Der PM2-Daemon kann noch unter einer älteren Node-Version laufen. + interpreter: process.env.DEPLOY_NODE_BINARY || process.execPath, cwd: '/var/www/harheimertc', instances: 1, autorestart: true, diff --git a/harheimertc.test.config.cjs b/harheimertc.test.config.cjs index 193ade6..cbe5678 100755 --- a/harheimertc.test.config.cjs +++ b/harheimertc.test.config.cjs @@ -51,8 +51,9 @@ module.exports = { { name: 'harheimertc.test', // Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode") - script: 'node', - args: '.output/server/index.mjs', + script: '.output/server/index.mjs', + // Der PM2-Daemon kann noch unter einer älteren Node-Version laufen. + interpreter: process.env.DEPLOY_NODE_BINARY || process.execPath, cwd: '/var/www/harheimertc.test', instances: 1, autorestart: true, diff --git a/package-lock.json b/package-lock.json index ffc18d9..dd52295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -701,8 +701,266 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -715,6 +973,150 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "devOptional": true, @@ -1559,6 +1961,8 @@ }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", "license": "MIT", "workspaces": [ "e2e/*" @@ -1579,13 +1983,63 @@ "@napi-rs/canvas-win32-x64-msvc": "0.1.80" } }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "node_modules/@napi-rs/canvas-android-arm64": { "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, "os": [ "linux" ], @@ -1593,6 +2047,117 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -6047,6 +6612,8 @@ }, "node_modules/esbuild": { "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/scripts/deploy-runtime.sh b/scripts/deploy-runtime.sh new file mode 100644 index 0000000..4d75e89 --- /dev/null +++ b/scripts/deploy-runtime.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Sourced by the test and production deployment scripts. + +deploy_restart_pm2() { + local instance_name="$1" + local config_file="$2" + local node_binary + + if [ ! -f "$config_file" ]; then + echo "ERROR: PM2-Konfigurationsdatei nicht gefunden: $config_file" + return 1 + fi + + node_binary="$(node -p 'process.execPath')" || return 1 + echo " Starting/restarting $instance_name using Node: $node_binary" + # PM2 retains the old script path even when restarting via a config file. + # Recreate only this app so legacy `script: node` entries migrate as well. + if pm2 describe "$instance_name" >/dev/null 2>&1; then + pm2 delete "$instance_name" || return 1 + fi + DEPLOY_NODE_BINARY="$node_binary" pm2 start "$config_file" \ + --only "$instance_name" --update-env +} + +deploy_wait_for_http() { + local instance_name="$1" + local url="$2" + local attempt + + echo " Waiting for $instance_name: $url" + for ((attempt = 1; attempt <= 30; attempt++)); do + if curl --fail --silent --max-time 2 "$url" >/dev/null; then + echo " ✓ HTTP-Healthcheck erfolgreich: $url" + return 0 + fi + if [ "$attempt" -lt 30 ]; then + sleep 2 + fi + done + + echo "ERROR: $instance_name antwortet nicht erfolgreich auf $url" + pm2 describe "$instance_name" || true + pm2 logs "$instance_name" --nostream --lines 30 || true + return 1 +} diff --git a/tests/deploy-runtime.spec.ts b/tests/deploy-runtime.spec.ts new file mode 100644 index 0000000..7e6dcbb --- /dev/null +++ b/tests/deploy-runtime.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import path from 'node:path' + +function runHelper(script: string) { + return spawnSync('bash', ['-c', 'set -euo pipefail; source "$DEPLOY_HELPER"; ' + script], { + encoding: 'utf8', + env: { + ...process.env, + DEPLOY_HELPER: path.resolve('scripts/deploy-runtime.sh'), + DEPLOY_CONFIG: path.resolve('harheimertc.test.config.cjs') + } + }) +} + +describe('Deployment runtime', () => { + it('reloads the selected app configuration with an absolute Node runtime', () => { + const result = runHelper(` + pm2() { + if [ "$1" = describe ]; then return 0; fi + printf 'runtime=%s\\n' "\${DEPLOY_NODE_BINARY:-}" + printf '%s\\n' "$@" + } + deploy_restart_pm2 harheimertc.test "$DEPLOY_CONFIG" + `) + expect(result.status).toBe(0) + expect(result.stdout).toContain('runtime=/') + expect(result.stdout).toContain('delete\nharheimertc.test') + expect(result.stdout).toContain('start\n' + path.resolve('harheimertc.test.config.cjs')) + expect(result.stdout).toContain('--only\nharheimertc.test\n--update-env') + }) + + it('waits for HTTP readiness after initial connection failures', () => { + const result = runHelper(` + attempts=0 + curl() { attempts=$((attempts + 1)); [ "$attempts" -ge 3 ]; } + sleep() { :; } + pm2() { echo 'Unexpected diagnostics'; return 1; } + deploy_wait_for_http harheimertc.test http://127.0.0.1:3102/ + [ "$attempts" -eq 3 ] + `) + expect(result.status).toBe(0) + expect(result.stdout).toContain('HTTP-Healthcheck erfolgreich') + expect(result.stdout).not.toContain('Unexpected diagnostics') + }) + + it('fails with PM2 diagnostics when the server never becomes ready', () => { + const result = runHelper(` + curl() { return 1; } + sleep() { :; } + pm2() { printf '%s\\n' "$*"; } + deploy_wait_for_http harheimertc.test http://127.0.0.1:3102/ + `) + expect(result.status).toBe(1) + expect(result.stdout).toContain('describe harheimertc.test') + expect(result.stdout).toContain('logs harheimertc.test --nostream --lines 30') + }) +}) -- 2.34.1 From 472bec45190e11ebe042af08bc6a6131f9dce004 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 29 Oct 2026 21:49:51 +0100 Subject: [PATCH 4/5] fix: show live ticker across app and website and suppress review emails --- PLAYSTORE_REVIEW.md | 32 +++++++++ .../ui/components/AppNavigationHeader.kt | 4 +- .../de/harheimertc/ui/navigation/NavGraph.kt | 25 ++++--- android-app/gradle.properties | 4 +- app.vue | 4 +- components/LiveTickerBanner.vue | 14 ++-- env.example | 5 ++ harheimertc.config.cjs | 3 + harheimertc.test.config.cjs | 3 + package.json | 2 +- scripts/setup-playstore-review.js | 42 +++++++++++ .../auth/passkeys/recovery/request.post.js | 4 +- server/api/auth/register-passkey.post.js | 2 +- server/api/auth/register.post.js | 2 +- server/api/auth/reset-password.post.js | 4 +- .../cms/contact-requests/[id]/reply.post.js | 8 +-- server/api/cms/users/approve.post.js | 4 +- server/api/contact.post.js | 8 +-- server/api/membership/generate-pdf.post.js | 6 +- server/api/newsletter/[id]/send.post.js | 4 +- .../groups/[id]/posts/create.post.js | 4 +- .../groups/[id]/subscribers/add.post.js | 14 ++-- server/api/newsletter/subscribe.post.js | 14 ++-- server/utils/email-service.js | 16 +++-- server/utils/mail-transport.js | 50 ++++++++++++++ tests/auth-endpoints.spec.ts | 1 + tests/cms-users-endpoints.spec.ts | 1 + tests/mail-transport.spec.ts | 69 +++++++++++++++++++ tests/public-endpoints.spec.ts | 1 + 29 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 PLAYSTORE_REVIEW.md create mode 100644 scripts/setup-playstore-review.js create mode 100644 server/utils/mail-transport.js create mode 100644 tests/mail-transport.spec.ts diff --git a/PLAYSTORE_REVIEW.md b/PLAYSTORE_REVIEW.md new file mode 100644 index 0000000..765c5aa --- /dev/null +++ b/PLAYSTORE_REVIEW.md @@ -0,0 +1,32 @@ +# Google Play: Zugang für die App-Prüfung + +In der Play Console unter **Richtlinien und Programme → App-Inhalte → App-Zugriff / Anmeldedaten** angeben, dass einige Funktionen eine Anmeldung erfordern. Eine neue Anleitung mit Titel `Harheimer TC review account` anlegen. + +## Konto auf dem Backend der eingereichten App einrichten + +Nach Deployment dieser Änderungen im Projektverzeichnis des entsprechenden Servers ausführen: + +```bash +node scripts/setup-playstore-review.js +``` + +Das Skript liest die dortige `.env` einschließlich `ENCRYPTION_KEY`, sichert die bisherige Benutzerdatei und erstellt ein aktives, ausgeblendetes Mitgliedskonto. Standardadresse: `playstore-review@harheimertc.invalid`. Dies ist bewusst kein echtes Postfach. Das zufällig erzeugte Passwort wird einmal ausgegeben und muss sicher abgelegt werden. Bei erneutem Aufruf bleibt es unverändert. Optional können `PLAYSTORE_REVIEW_EMAIL` und `PLAYSTORE_REVIEW_PASSWORD` gesetzt werden; ein explizites Passwort aktualisiert das bestehende Review-Konto. Kein reguläres Konto wird umgewandelt. + +In der Play Console die ausgegebene E-Mail-Adresse und das Passwort eintragen. Zusätzliche Anweisungen auf Englisch: + +> Open the app and go to Login. Sign in with the email address and password provided above. This account is already activated and provides access to the member features. No registration, email verification, one-time code or approval is required. Please stay signed in while testing forms. Outgoing emails triggered by this account are suppressed by the server. When a form asks for an email address, use the review account email provided above, including when testing while signed out. + +Die Zugangsdaten dauerhaft gültig halten. Das Konto ist ein Mitglied, kein CMS-Administrator. + +## Versandsperre + +Alle SMTP-Versandstellen verwenden dieselbe serverseitige Sperre: + +- Für authentifizierte Konten mit `accountType: playstore_review` oder der konfigurierten `PLAYSTORE_REVIEW_EMAIL` wird kein SMTP-Versand ausgeführt, auch bei abweichender Formularadresse. +- Mails an die Review-Adresse sowie Kontaktmails mit dieser Antwortadresse werden auch ohne Anmeldung unterdrückt. Registrierung und Mitgliedsanträge prüfen zusätzlich die Formularadresse. Gemischte Empfängerlisten mit einem Review-Konto werden vollständig unterdrückt. +- `MAIL_DELIVERY_DISABLED=true` unterdrückt sämtliche SMTP-Mails einer Installation. Die PM2-Testkonfiguration setzt dies standardmäßig; Produktion bleibt ohne diese Einstellung aktiv. Nach Konfigurationsänderungen den Backend-Prozess mit aktualisierter Umgebung neu starten. +- `DEBUG=true` allein verhindert keinen Mailversand. + +Die Versandsperre ist keine isolierte Demo-Datenbank: Formulare können weiterhin Daten speichern und Push-Benachrichtigungen auslösen. Ohne Anmeldung und mit einer fremden Formularadresse lässt sich eine Anfrage keinem Review-Konto zuordnen. Für vollständig mailfreie Tests daher eine separate Testinstanz mit `MAIL_DELIVERY_DISABLED=true` verwenden und sicherstellen, dass der eingereichte App-Build dieses Backend nutzt. + +Google-Anforderungen: https://support.google.com/googleplay/android-developer/answer/15748846 diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 9c76bb8..40c6780 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -80,8 +80,8 @@ private fun CompactNavigation( navigationState: NavigationUiState = NavigationUiState(), ) { val routeSection = menuSection(selectedRoute) - val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } - val section = routeSection ?: sectionOverride.value + val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf(null) } + val section = sectionOverride.value ?: routeSection val subItems = submenu(section, navigationState) val mainScroll = rememberScrollState() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index 4d6f3e1..a618ba5 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,13 +46,13 @@ fun NavGraph( LaunchedEffect(currentRoute) { navigationViewModel.refreshSession() } - LaunchedEffect(currentRoute, navigationState.loggedIn) { - navigationViewModel.setLiveTickerActive( - navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true), - ) + LaunchedEffect(navigationState.loggedIn) { + navigationViewModel.setLiveTickerActive(navigationState.loggedIn) } - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val persistentNavigation = maxWidth >= 600.dp + BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) { + val tabletNavigation = maxWidth >= 600.dp + // Signed-in phones share the same menu and ticker above every screen. + val persistentNavigation = tabletNavigation || navigationState.loggedIn Column(modifier = Modifier.fillMaxSize()) { navigationState.connectionNote?.let { message -> Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) { @@ -74,14 +75,16 @@ fun NavGraph( } } }, - webTabletNavigation = true, + webTabletNavigation = tabletNavigation, navigationState = navigationState, ) } - LiveTickerBanner( - matches = navigationState.liveTickerMatches, - loaded = navigationState.liveTickerLoaded, - ) + if (navigationState.loggedIn) { + LiveTickerBanner( + matches = navigationState.liveTickerMatches, + loaded = navigationState.liveTickerLoaded, + ) + } NavHost( navController = navController, startDestination = startDestination, diff --git a/android-app/gradle.properties b/android-app/gradle.properties index f29737d..27cfdc4 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=39 -ANDROID_VERSION_NAME=0.11.1 +ANDROID_VERSION_CODE=40 +ANDROID_VERSION_NAME=0.11.2 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/app.vue b/app.vue index 6493c6b..a0c43e1 100755 --- a/app.vue +++ b/app.vue @@ -1,8 +1,8 @@