Compare commits
5 Commits
d26737e38c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c550fe8581 | ||
|
|
472bec4519 | ||
|
|
10a37180de | ||
|
|
4ecc2cc90c | ||
|
|
7a991a50c7 |
32
PLAYSTORE_REVIEW.md
Normal file
32
PLAYSTORE_REVIEW.md
Normal file
@@ -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
|
||||
@@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||
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,
|
||||
|
||||
@@ -80,8 +80,8 @@ private fun CompactNavigation(
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = routeSection ?: sectionOverride.value
|
||||
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: routeSection
|
||||
val subItems = submenu(section, navigationState)
|
||||
|
||||
val mainScroll = rememberScrollState()
|
||||
|
||||
@@ -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<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
@@ -45,11 +60,56 @@ fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp),
|
||||
.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 {}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, 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) {
|
||||
@@ -74,5 +134,4 @@ fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<NotificationSettingsUiState> = _state
|
||||
private val saveMutex = Mutex()
|
||||
private var settingsRevision = 0L
|
||||
|
||||
init {
|
||||
load()
|
||||
@@ -66,16 +70,23 @@ 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTeam(slug: String, selected: Boolean) {
|
||||
val current = _state.value.settings
|
||||
|
||||
@@ -38,7 +38,7 @@ class LoginViewModelTest {
|
||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||
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<de.harheimertc.repositories.PasskeyRepository>()
|
||||
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")
|
||||
|
||||
@@ -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<NotificationPreferencesRepository>(relaxed = true)
|
||||
private val teams = mockk<MannschaftenRepository>()
|
||||
private val login = mockk<LoginRepository>()
|
||||
|
||||
@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<PushTokenRepository>())
|
||||
|
||||
@Test
|
||||
fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) {
|
||||
val firstResponse = CompletableDeferred<Result<NotificationPreferences>>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=40
|
||||
ANDROID_VERSION_NAME=0.11.2
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
4
app.vue
4
app.vue
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<div class="h-screen flex flex-col overflow-hidden pt-20">
|
||||
<Navigation />
|
||||
<LiveTickerBanner />
|
||||
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
|
||||
<main class="min-h-0 flex-1 overflow-y-auto pb-20 sm:pb-0">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<section
|
||||
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"
|
||||
v-if="authStore.isLoggedIn && matches.length"
|
||||
class="relative z-40 w-full shrink-0 border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6">
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-2 text-sm sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
@@ -12,6 +12,30 @@
|
||||
>
|
||||
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
:aria-pressed="paused"
|
||||
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
|
||||
@click="paused = !paused"
|
||||
>
|
||||
{{ paused ? '▶' : 'Ⅱ' }}
|
||||
</button>
|
||||
<div
|
||||
class="ticker-viewport min-w-0 flex-1"
|
||||
:class="{ 'ticker-paused': paused }"
|
||||
>
|
||||
<div
|
||||
ref="tickerTrack"
|
||||
class="ticker-track"
|
||||
:style="{ '--ticker-duration': `${tickerDuration}s` }"
|
||||
>
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="ticker-group"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="match in matches"
|
||||
:key="match.meetingId"
|
||||
@@ -29,24 +53,38 @@
|
||||
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const matches = ref([])
|
||||
const soundEnabled = ref(false)
|
||||
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
||||
const paused = ref(false)
|
||||
const tickerTrack = ref(null)
|
||||
const tickerDuration = ref(30)
|
||||
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}`,
|
||||
@@ -86,7 +124,7 @@ function toggleSound() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isInternal.value) {
|
||||
if (!authStore.isLoggedIn) {
|
||||
matches.value = []
|
||||
tickerLoaded = false
|
||||
previousStates = new Map()
|
||||
@@ -94,6 +132,7 @@ async function load() {
|
||||
}
|
||||
try {
|
||||
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
|
||||
if (!authStore.isLoggedIn) return
|
||||
const nextStates = stateByMeeting(nextMatches)
|
||||
if (tickerLoaded && soundEnabled.value && hasRelevantChange(nextStates)) playPing()
|
||||
previousStates = nextStates
|
||||
@@ -107,5 +146,46 @@ onMounted(() => {
|
||||
timer = setInterval(load, 30_000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
watch(isInternal, load)
|
||||
watch(() => authStore.isLoggedIn, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticker-viewport {
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticker-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: ticker-scroll var(--ticker-duration) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.ticker-group {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
min-width: 100cqw;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.ticker-paused .ticker-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
|
||||
}
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ticker-viewport { overflow-x: auto; }
|
||||
.ticker-track { animation: none; will-change: auto; }
|
||||
.ticker-group[aria-hidden="true"] { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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! ==="
|
||||
|
||||
@@ -14,6 +14,11 @@ SMTP_PORT=587
|
||||
SMTP_USER=tsschulz@tsschulz.de
|
||||
SMTP_PASS=your_app_password_here
|
||||
|
||||
# Unterdrückt ALLE SMTP-Mails dieser Installation (für lokale Tests empfohlen).
|
||||
MAIL_DELIVERY_DISABLED=true
|
||||
# Optional: Review-Adresse; Einrichtung siehe PLAYSTORE_REVIEW.md
|
||||
PLAYSTORE_REVIEW_EMAIL=playstore-review@harheimertc.invalid
|
||||
|
||||
# Alternative für GMX:
|
||||
# SMTP_HOST=mail.gmx.net
|
||||
# SMTP_PORT=587
|
||||
|
||||
@@ -16,6 +16,9 @@ function createEnv(port) {
|
||||
// Secrets/Config (loaded from .env above, if present)
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED,
|
||||
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
|
||||
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
@@ -49,8 +52,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,
|
||||
|
||||
@@ -18,6 +18,9 @@ function createEnv(port) {
|
||||
// Secrets/Config (loaded from .env above, if present)
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED || 'true',
|
||||
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
|
||||
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
@@ -51,8 +54,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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
575
package-lock.json
generated
575
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"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": {
|
||||
|
||||
45
scripts/deploy-runtime.sh
Normal file
45
scripts/deploy-runtime.sh
Normal file
@@ -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
|
||||
}
|
||||
22
scripts/require-safe-node.cjs
Normal file
22
scripts/require-safe-node.cjs
Normal file
@@ -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;
|
||||
44
scripts/setup-playstore-review.js
Normal file
44
scripts/setup-playstore-review.js
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import 'dotenv/config'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { decryptObject } from '../server/utils/encryption.js'
|
||||
import { hashPassword, writeUsers } from '../server/utils/auth.js'
|
||||
|
||||
// Run in the target installation's root, with its encryption key loaded.
|
||||
const email = String(process.env.PLAYSTORE_REVIEW_EMAIL || 'playstore-review@harheimertc.invalid').trim().toLowerCase()
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Ungültige PLAYSTORE_REVIEW_EMAIL')
|
||||
if (!process.env.ENCRYPTION_KEY) throw new Error('ENCRYPTION_KEY der Zielinstallation fehlt')
|
||||
const root = process.cwd().endsWith('.output') ? path.resolve('..') : process.cwd()
|
||||
const file = path.join(root, 'server/data/users.json')
|
||||
// Fail closed: never replace an unreadable user database with an empty list.
|
||||
const raw = await fs.readFile(file, 'utf8')
|
||||
let users
|
||||
try { users = JSON.parse(raw) } catch { users = decryptObject(raw, process.env.ENCRYPTION_KEY) }
|
||||
if (!Array.isArray(users)) throw new Error('Ungültiges Format der Benutzerdaten')
|
||||
let user = users.find(candidate => String(candidate.email || '').trim().toLowerCase() === email)
|
||||
if (user && user.accountType !== 'playstore_review') throw new Error('E-Mail wird bereits von einem regulären Konto verwendet')
|
||||
if (user && !process.env.PLAYSTORE_REVIEW_PASSWORD) {
|
||||
console.log(`Review-Konto existiert bereits: ${email}. Passwort bleibt unverändert.`)
|
||||
} else {
|
||||
const password = process.env.PLAYSTORE_REVIEW_PASSWORD || crypto.randomBytes(24).toString('base64url')
|
||||
if (password.length < 16) throw new Error('Review-Passwort muss mindestens 16 Zeichen haben')
|
||||
if (!user) {
|
||||
user = { id: crypto.randomUUID(), email, created: new Date().toISOString() }
|
||||
users.push(user)
|
||||
}
|
||||
user.name = 'Google Play Review'
|
||||
user.password = await hashPassword(password)
|
||||
user.roles = ['mitglied']
|
||||
user.active = true
|
||||
user.hidden = true
|
||||
user.accountType = 'playstore_review'
|
||||
user.visibility = { showBirthday: false }
|
||||
delete user.role
|
||||
await fs.copyFile(file, `${file}.before-playstore-${Date.now()}`)
|
||||
if (!await writeUsers(users)) throw new Error('Review-Konto konnte nicht gespeichert werden')
|
||||
console.log(`E-Mail: ${email}`)
|
||||
if (!process.env.PLAYSTORE_REVIEW_PASSWORD) console.log(`Passwort (sicher ablegen): ${password}`)
|
||||
else console.log('Passwort: aus PLAYSTORE_REVIEW_PASSWORD gesetzt')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../../utils/mail-transport.js'
|
||||
import { readUsers, writeUsers } from '../../../../utils/auth.js'
|
||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.js'
|
||||
import { generateRecoveryToken, hashRecoveryToken, pruneRecoveryTokens } from '../../../../utils/passkey-recovery.js'
|
||||
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
|
||||
if (smtpUser && smtpPass) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
@@ -262,7 +262,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// Send notification emails through the same central recipient logic as password registration.
|
||||
try {
|
||||
await sendRegistrationNotification({ name, email, phone })
|
||||
await sendRegistrationNotification({ name, email, phone }, event)
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// Send notification to Vorstand/admin via central email service
|
||||
try {
|
||||
await sendRegistrationNotification({ name, email, phone })
|
||||
await sendRegistrationNotification({ name, email, phone }, event)
|
||||
} catch (emailError) {
|
||||
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readUsers, writeUsers } from '../../utils/auth.js'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../utils/mail-transport.js'
|
||||
import crypto from 'crypto'
|
||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||
@@ -152,7 +152,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
await logStep('token_storage', 'completed', { userId: user.id })
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../../utils/mail-transport.js'
|
||||
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
|
||||
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
|
||||
|
||||
function createTransporter() {
|
||||
function createTransporter(event) {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||
if (!smtpUser || !smtpPass) return null
|
||||
|
||||
return nodemailer.createTransport({
|
||||
return createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
|
||||
}
|
||||
|
||||
const transporter = createTransporter()
|
||||
const transporter = createTransporter(event)
|
||||
if (!transporter) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../utils/mail-transport.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -58,7 +58,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
||||
// Continue without sending email
|
||||
} else {
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../utils/mail-transport.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import { createContactRequest } from '../utils/contact-requests.js'
|
||||
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
||||
@@ -97,12 +97,12 @@ async function collectRecipients(config) {
|
||||
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
||||
}
|
||||
|
||||
function createTransporter() {
|
||||
function createTransporter(event) {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||
if (!smtpUser || !smtpPass) return null
|
||||
|
||||
return nodemailer.createTransport({
|
||||
return createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
@@ -144,7 +144,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const config = await loadConfig()
|
||||
const recipients = await collectRecipients(config)
|
||||
const transporter = createTransporter()
|
||||
const transporter = createTransporter(event)
|
||||
|
||||
if (!transporter) {
|
||||
return {
|
||||
|
||||
@@ -626,7 +626,7 @@ export default defineEventHandler(async (event) => {
|
||||
let emailResult
|
||||
if (usedTemplate) {
|
||||
// E-Mail senden via zentralen Service (pass full path)
|
||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
|
||||
// Antragsdaten verschlüsselt speichern
|
||||
const encryptedData = JSON.stringify(data)
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
@@ -687,7 +687,7 @@ export default defineEventHandler(async (event) => {
|
||||
await fs.copyFile(pdfPath, finalPdfPath)
|
||||
|
||||
// E-Mail senden via zentralen Service (pass full path)
|
||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
|
||||
|
||||
// Antragsdaten verschlüsselt speichern
|
||||
const encryptedData = JSON.stringify(data)
|
||||
@@ -720,7 +720,7 @@ export default defineEventHandler(async (event) => {
|
||||
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
||||
|
||||
// E-Mail senden (Fallback)
|
||||
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
|
||||
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`), event)
|
||||
|
||||
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
||||
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../utils/newsletter.js'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../utils/mail-transport.js'
|
||||
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
// filename is always a hardcoded constant (e.g., 'newsletter.json'), never user input
|
||||
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path'
|
||||
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../../../utils/mail-transport.js'
|
||||
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
||||
@@ -222,7 +222,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../../../../utils/mail-transport.js'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
} else {
|
||||
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
|
||||
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
|
||||
return {
|
||||
success: true,
|
||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||
@@ -124,7 +124,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
} else {
|
||||
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||
return {
|
||||
success: true,
|
||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
} else {
|
||||
// Bestätigungsmail senden mit individueller Nachricht
|
||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||
return {
|
||||
success: true,
|
||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
|
||||
await writeSubscribers(subscribers)
|
||||
|
||||
// Bestätigungsmail senden mit individueller Nachricht
|
||||
await sendConfirmationEmail(email, name, confirmationToken, group.name, customMessage, user.name)
|
||||
await sendConfirmationEmail(event, email, name, confirmationToken, group.name, customMessage, user.name)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -196,7 +196,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function sendConfirmationEmail(email, name, token, groupName, customMessage = null, inviterName = null) {
|
||||
async function sendConfirmationEmail(event, email, name, token, groupName, customMessage = null, inviterName = null) {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
|
||||
@@ -208,7 +208,7 @@ async function sendConfirmationEmail(email, name, token, groupName, customMessag
|
||||
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport } from '../../utils/mail-transport.js'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
@@ -129,7 +129,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
} else {
|
||||
// Bestätigungsmail erneut senden
|
||||
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name)
|
||||
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name)
|
||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||
return {
|
||||
success: true,
|
||||
@@ -158,7 +158,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
} else {
|
||||
// Bestätigungsmail erneut senden
|
||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
|
||||
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
|
||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||
return {
|
||||
success: true,
|
||||
@@ -186,7 +186,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
} else {
|
||||
// Bestätigungsmail senden
|
||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
|
||||
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
|
||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||
return {
|
||||
success: true,
|
||||
@@ -215,7 +215,7 @@ export default defineEventHandler(async (event) => {
|
||||
await writeSubscribers(subscribers)
|
||||
|
||||
// Bestätigungsmail senden
|
||||
await sendConfirmationEmail(email, name, confirmationToken, group.name)
|
||||
await sendConfirmationEmail(event, email, name, confirmationToken, group.name)
|
||||
|
||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||
return {
|
||||
@@ -234,7 +234,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function sendConfirmationEmail(email, name, token, groupName) {
|
||||
async function sendConfirmationEmail(event, email, name, token, groupName) {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
|
||||
@@ -246,7 +246,7 @@ async function sendConfirmationEmail(email, name, token, groupName) {
|
||||
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
const transporter = createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false,
|
||||
|
||||
6
server/plugins/require-safe-node.js
Normal file
6
server/plugins/require-safe-node.js
Normal file
@@ -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(() => {});
|
||||
@@ -3,7 +3,7 @@
|
||||
* Clean Code: Single Responsibility Principle
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createMailTransport, shouldSuppressMail } from './mail-transport.js'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
@@ -103,7 +103,7 @@ async function getEmailRecipients(data, config) {
|
||||
* Creates email transporter
|
||||
* @returns {Object} Nodemailer transporter
|
||||
*/
|
||||
function createTransporter() {
|
||||
function createTransporter(event) {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
|
||||
@@ -114,7 +114,7 @@ function createTransporter() {
|
||||
)
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
return createMailTransport(event, {
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
@@ -131,13 +131,14 @@ function createTransporter() {
|
||||
* @param {string} pdfPath - Path to generated PDF
|
||||
* @returns {Promise<Object>} Email result
|
||||
*/
|
||||
export async function sendMembershipEmail(data, pdfPath) {
|
||||
export async function sendMembershipEmail(data, pdfPath, event) {
|
||||
try {
|
||||
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
|
||||
const config = await loadConfig()
|
||||
const recipients = await getEmailRecipients(data, config)
|
||||
|
||||
// Create transporter
|
||||
const transporter = createTransporter()
|
||||
const transporter = createTransporter(event)
|
||||
|
||||
// Email content
|
||||
const subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
|
||||
@@ -187,13 +188,14 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
||||
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
|
||||
* @param {Object} data - { name, email, phone }
|
||||
*/
|
||||
export async function sendRegistrationNotification(data) {
|
||||
export async function sendRegistrationNotification(data, event) {
|
||||
try {
|
||||
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
|
||||
const config = await loadConfig()
|
||||
const recipients = await getEmailRecipients(data, config)
|
||||
|
||||
// Create transporter
|
||||
const transporter = createTransporter()
|
||||
const transporter = createTransporter(event)
|
||||
|
||||
// Notify Vorstand/admin
|
||||
const adminSubject = 'Neue Registrierung - Harheimer TC'
|
||||
|
||||
50
server/utils/mail-transport.js
Normal file
50
server/utils/mail-transport.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import * as auth from './auth.js'
|
||||
|
||||
const normalize = value => String(value || '').trim().toLowerCase()
|
||||
const enabled = value => ['1', 'true', 'yes', 'on'].includes(normalize(value))
|
||||
|
||||
export function isReviewUser(user) {
|
||||
if (!user) return false
|
||||
const email = normalize(user.email)
|
||||
return normalize(user.accountType) === 'playstore_review' ||
|
||||
Boolean(email && email === normalize(process.env.PLAYSTORE_REVIEW_EMAIL))
|
||||
}
|
||||
|
||||
function addresses(values) {
|
||||
return values.flat(Infinity).filter(Boolean).flatMap(value => {
|
||||
if (typeof value === 'object') return [normalize(value.address)]
|
||||
return String(value).split(',').map(part => normalize(part.match(/<([^>]+)>/)?.[1] || part))
|
||||
})
|
||||
}
|
||||
|
||||
export async function shouldSuppressMail(event, emails = []) {
|
||||
if (enabled(process.env.MAIL_DELIVERY_DISABLED)) return true
|
||||
|
||||
// Use the authenticated account, never a client-provided demo flag.
|
||||
if (event) {
|
||||
const token = auth.getTokenFromEvent(event)
|
||||
if (token && isReviewUser(await auth.getUserFromToken(token))) return true
|
||||
}
|
||||
|
||||
const targets = addresses(emails)
|
||||
if (!targets.length) return false
|
||||
if (targets.includes(normalize(process.env.PLAYSTORE_REVIEW_EMAIL))) return true
|
||||
const users = await auth.readUsers()
|
||||
return users.some(user => isReviewUser(user) && targets.includes(normalize(user.email)))
|
||||
}
|
||||
|
||||
// All SMTP entry points go through this gate, including newsletter batches.
|
||||
// Create the real transport lazily so suppressed mail cannot open a connection.
|
||||
export function createMailTransport(event, options) {
|
||||
let transport
|
||||
return {
|
||||
async sendMail(mail) {
|
||||
if (await shouldSuppressMail(event, [mail.to, mail.cc, mail.bcc, mail.replyTo])) {
|
||||
return { suppressed: true, messageId: null, accepted: [], rejected: [] }
|
||||
}
|
||||
transport ||= nodemailer.createTransport(options)
|
||||
return transport.sendMail(mail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import crypto from 'crypto'
|
||||
|
||||
vi.mock('../server/utils/auth.js', () => {
|
||||
return {
|
||||
getTokenFromEvent: vi.fn(() => undefined),
|
||||
readUsers: vi.fn(),
|
||||
writeUsers: vi.fn(),
|
||||
verifyPassword: vi.fn(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEvent, mockSuccessReadBody } from './setup'
|
||||
|
||||
vi.mock('../server/utils/auth.js', () => ({
|
||||
getTokenFromEvent: vi.fn(() => undefined),
|
||||
getUserFromToken: vi.fn(),
|
||||
readUsers: vi.fn(),
|
||||
writeUsers: vi.fn(),
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
58
tests/deploy-runtime.spec.ts
Normal file
58
tests/deploy-runtime.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
69
tests/mail-transport.spec.ts
Normal file
69
tests/mail-transport.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEvent } from './setup'
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: { createTransport: vi.fn(() => ({ sendMail: vi.fn().mockResolvedValue({ messageId: 'real' }) })) }
|
||||
}))
|
||||
vi.mock('../server/utils/auth.js', () => ({
|
||||
readUsers: vi.fn().mockResolvedValue([]),
|
||||
getTokenFromEvent: vi.fn(),
|
||||
getUserFromToken: vi.fn()
|
||||
}))
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
import * as auth from '../server/utils/auth.js'
|
||||
import { createMailTransport } from '../server/utils/mail-transport.js'
|
||||
import { sendMembershipEmail, sendRegistrationNotification } from '../server/utils/email-service.js'
|
||||
|
||||
describe('Mail delivery gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('MAIL_DELIVERY_DISABLED', '')
|
||||
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', '')
|
||||
auth.readUsers.mockResolvedValue([])
|
||||
auth.getTokenFromEvent.mockReturnValue(undefined)
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
it('does not create an SMTP transport when all delivery is disabled', async () => {
|
||||
vi.stubEnv('MAIL_DELIVERY_DISABLED', 'TRUE')
|
||||
expect(await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com' }))
|
||||
.toMatchObject({ suppressed: true, accepted: [] })
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses board mail for an authenticated review user with another form address', async () => {
|
||||
auth.getTokenFromEvent.mockReturnValue('verified-by-auth')
|
||||
auth.getUserFromToken.mockResolvedValue({ accountType: 'playstore_review' })
|
||||
await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com', replyTo: 'other@example.com' })
|
||||
expect(auth.getUserFromToken).toHaveBeenCalledWith('verified-by-auth')
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses signed-out contact mail using the configured review reply address', async () => {
|
||||
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
|
||||
await createMailTransport(undefined, {}).sendMail({ to: 'board@example.com', replyTo: 'Review <REVIEW@example.com>' })
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks stored review accounts in cc and bcc as well', async () => {
|
||||
auth.readUsers.mockResolvedValue([{ email: 'review@example.com', accountType: 'playstore_review' }])
|
||||
await createMailTransport(undefined, {}).sendMail({ to: 'member@example.com', bcc: [{ address: 'review@example.com' }] })
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses membership and registration notifications before SMTP/config loading', async () => {
|
||||
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
|
||||
expect(await sendMembershipEmail({ email: 'review@example.com' }, '/unused.pdf')).toMatchObject({ success: true, suppressed: true })
|
||||
expect(await sendRegistrationNotification({ email: 'review@example.com' })).toMatchObject({ success: true, suppressed: true })
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delivers ordinary member mail and does not trust a client demo flag', async () => {
|
||||
const event = createEvent({ body: { demo: true, accountType: 'playstore_review' } })
|
||||
const mail = { to: 'member@example.com', text: 'Hello' }
|
||||
expect(await createMailTransport(event, { host: 'smtp.example.com' }).sendMail(mail)).toEqual({ messageId: 'real' })
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.example.com' })
|
||||
expect(nodemailer.createTransport.mock.results[0].value.sendMail).toHaveBeenCalledWith(mail)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ vi.mock('../server/utils/news.js', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../server/utils/auth.js', () => ({
|
||||
getTokenFromEvent: vi.fn(() => undefined),
|
||||
readUsers: vi.fn(),
|
||||
migrateUserRoles: vi.fn((user) => {
|
||||
if (!user) return user
|
||||
|
||||
Reference in New Issue
Block a user