diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 409cf76..b4cfa5f 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -37,6 +37,12 @@ data class SpielplanResponse( val seasons: List = emptyList(), ) data class SeasonDto(val slug: String = "", val label: String = "") +data class MannschaftenSeasonsResponse( + val success: Boolean = false, + val seasons: List = emptyList(), + val currentSeason: String = "", + val defaultSeason: String = "", +) data class SpielDto( @param:Json(name = "Termin") val termin: String = "", @param:Json(name = "HeimMannschaft") val heimMannschaft: String = "", @@ -584,6 +590,9 @@ interface ApiService { @GET("/api/mannschaften") suspend fun mannschaften(@Query("season") season: String? = null): Response + @GET("/api/mannschaften/seasons") + suspend fun mannschaftenSeasons(): Response + @GET("/api/config") suspend fun config(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt index 8dc1a01..91a33e6 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt @@ -1,6 +1,8 @@ package de.harheimertc.repositories import de.harheimertc.data.ApiService +import de.harheimertc.data.MannschaftenSeasonsResponse +import de.harheimertc.data.SeasonDto import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -29,6 +31,12 @@ class MannschaftenRepository @Inject constructor(private val api: ApiService) { parseCsv(response.body()?.string().orEmpty()) } + suspend fun fetchSeasons(): Result = runCatching { + val response = api.mannschaftenSeasons() + if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") + response.body() ?: error("Saisons konnten nicht geladen werden.") + } + private fun parseCsv(csv: String): List = csv.lineSequence() .filter(String::isNotBlank) .drop(1) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt index a22b6dc..bc8dbdc 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt @@ -18,7 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -34,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -43,6 +48,7 @@ import de.harheimertc.repositories.Mannschaft import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 +import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary600 @@ -64,8 +70,17 @@ fun MannschaftenScreen( BackLink(navController, showBackNavigation) Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp)) Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp)) + if (state.seasons.isNotEmpty()) { + SeasonSelector( + seasons = state.seasons, + selectedSeason = state.selectedSeason, + onSeasonSelected = viewModel::selectSeason, + modifier = Modifier.padding(top = 14.dp), + ) + } } when { + state.seasonsLoading -> item { Loading() } state.loading -> item { Loading() } state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) } state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) } @@ -88,6 +103,38 @@ fun MannschaftenScreen( } } +@Composable +private fun SeasonSelector( + seasons: List, + selectedSeason: String, + onSeasonSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var open by remember { mutableStateOf(false) } + val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall) + BoxWithConstraints { + OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) { + Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start) + Text("v") + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + seasons.forEach { season -> + DropdownMenuItem( + text = { Text(season.label.ifBlank { season.slug }) }, + onClick = { + open = false + onSeasonSelected(season.slug) + }, + ) + } + } + } + } +} + @Composable private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) { Surface( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt index c7ebb88..6ab6d84 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.SpielDto import de.harheimertc.data.LeagueTableRowDto +import de.harheimertc.data.SeasonDto import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.SpielplanRepository @@ -17,6 +18,9 @@ data class MannschaftenUiState( val loading: Boolean = true, val error: String? = null, val teams: List = emptyList(), + val seasons: List = emptyList(), + val selectedSeason: String = "", + val seasonsLoading: Boolean = false, ) @HiltViewModel @@ -27,17 +31,69 @@ class MannschaftenViewModel @Inject constructor( val state: StateFlow = _state init { - load() + loadSeasonsAndMannschaften() } fun load() { viewModelScope.launch { - _state.value = MannschaftenUiState(loading = true) - repository.fetchMannschaften() - .onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) } - .onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") } + val season = _state.value.selectedSeason.ifBlank { null } + _state.value = _state.value.copy(loading = true, error = null) + repository.fetchMannschaften(season) + .onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) } + .onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") } } } + + fun selectSeason(season: String) { + if (season == _state.value.selectedSeason) return + _state.value = _state.value.copy(selectedSeason = season) + load() + } + + private fun loadSeasonsAndMannschaften() { + viewModelScope.launch { + _state.value = _state.value.copy(seasonsLoading = true, error = null) + repository.fetchSeasons() + .onSuccess { response -> + val currentSeason = getCurrentSeasonSlug() + val seasons = response.seasons.ifEmpty { listOf(SeasonDto(slug = response.currentSeason.ifBlank { currentSeason }, label = formatSeasonLabel(response.currentSeason.ifBlank { currentSeason }))) } + val selectedSeason = when { + response.defaultSeason.isNotBlank() -> response.defaultSeason + seasons.any { it.slug == currentSeason } -> currentSeason + seasons.isNotEmpty() -> seasons.first().slug + else -> currentSeason + } + _state.value = _state.value.copy( + seasonsLoading = false, + seasons = seasons, + selectedSeason = selectedSeason, + ) + load() + } + .onFailure { + val currentSeason = getCurrentSeasonSlug() + _state.value = _state.value.copy( + seasonsLoading = false, + seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))), + selectedSeason = currentSeason, + ) + load() + } + } + } + + private fun getCurrentSeasonSlug(): String { + val now = java.util.Calendar.getInstance() + val year = now.get(java.util.Calendar.YEAR) + val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1 + val endYear = startYear + 1 + return "%02d--%02d".format(startYear % 100, endYear % 100) + } + + private fun formatSeasonLabel(seasonSlug: String): String { + val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug + return "20${match.groupValues[1]}/${match.groupValues[2]}" + } } data class MannschaftDetailUiState( diff --git a/android-app/gradle.properties b/android-app/gradle.properties index acf7727..cd95f8c 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=19 -ANDROID_VERSION_NAME=0.9.14 +ANDROID_VERSION_CODE=20 +ANDROID_VERSION_NAME=0.9.15 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/package.json b/package.json index 44b67c4..dc86fdc 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,12 @@ "start": "nuxt start --port 3100", "postinstall": "nuxt prepare", "test": "vitest run", + "test:data-rotation": "vitest run tests/data-file-rotation.spec.ts", "check-security": "node scripts/verify-no-public-writes.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js", "sync-public-data": "node scripts/sync-public-data.js", + "data-backups:list": "node scripts/data-backup-restore.js list", + "data-backups:restore": "node scripts/data-backup-restore.js restore", "hero:prepare": "node scripts/prepare-hero-variants.mjs", "import-spielplan": "node scripts/import-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js", diff --git a/scripts/data-backup-restore.js b/scripts/data-backup-restore.js new file mode 100644 index 0000000..eac9936 --- /dev/null +++ b/scripts/data-backup-restore.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +import path from 'path' +import { + getBackupDirectoryForDataFile, + listDataFileBackups, + restoreDataFileBackup +} from '../server/utils/data-file-rotation.js' + +const FILES = { + 'users.json': getDataPath('users.json'), + 'sessions.json': getDataPath('sessions.json'), + 'members.json': getDataPath('members.json'), + 'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'), + 'news.json': getDataPath('news.json'), + 'termine.csv': getDataPath('termine.csv'), + 'contact-requests.json': getDataPath('contact-requests.json') +} + +function getDataPath(filename) { + const cwd = process.cwd() + if (cwd.endsWith('.output')) { + return path.join(cwd, '../server/data', filename) + } + return path.join(cwd, 'server/data', filename) +} + +function parseArg(name) { + const index = process.argv.findIndex((arg) => arg === name) + if (index === -1) return null + const next = process.argv[index + 1] + if (!next || next.startsWith('--')) return null + return next +} + +function hasFlag(name) { + return process.argv.includes(name) +} + +function printUsage() { + console.log('Verwendung:') + console.log(' node scripts/data-backup-restore.js list [--file users.json]') + console.log(' node scripts/data-backup-restore.js restore --file users.json --latest') + console.log(' node scripts/data-backup-restore.js restore --file users.json --backup ') + console.log('') + console.log('Optionen:') + console.log(' --file Eine der bekannten Daten-Dateien') + console.log(' --latest Stellt das neueste Backup wieder her') + console.log(' --backup Konkreter Backup-Dateiname (*.bak)') + console.log('') + console.log('Bekannte Dateien:') + Object.keys(FILES).forEach((name) => console.log(` - ${name}`)) +} + +async function listCommand() { + const requestedFile = parseArg('--file') + const names = requestedFile ? [requestedFile] : Object.keys(FILES) + + for (const name of names) { + const dataPath = FILES[name] + if (!dataPath) { + console.error(`Unbekannte Datei: ${name}`) + process.exitCode = 1 + continue + } + + const backups = await listDataFileBackups(dataPath) + const backupDir = getBackupDirectoryForDataFile(dataPath) + + console.log(`\n${name}`) + console.log(` Datenpfad: ${dataPath}`) + console.log(` Backup-Ordner: ${backupDir}`) + + if (backups.length === 0) { + console.log(' Backups: keine') + continue + } + + console.log(` Backups (${backups.length}, neuestes zuerst):`) + backups.slice(0, 15).forEach((backup) => { + console.log(` - ${backup}`) + }) + if (backups.length > 15) { + console.log(` ... (${backups.length - 15} weitere)`) + } + } +} + +async function restoreCommand() { + const fileName = parseArg('--file') + if (!fileName) { + console.error('Fehlend: --file ') + printUsage() + process.exit(1) + } + + const dataPath = FILES[fileName] + if (!dataPath) { + console.error(`Unbekannte Datei: ${fileName}`) + process.exit(1) + } + + const backups = await listDataFileBackups(dataPath) + if (backups.length === 0) { + console.error(`Keine Backups gefunden für ${fileName}`) + process.exit(1) + } + + const backupName = parseArg('--backup') + const latest = hasFlag('--latest') + + let targetBackup = backupName + if (!targetBackup && latest) { + targetBackup = backups[0] + } + + if (!targetBackup) { + console.error('Bitte --latest oder --backup angeben') + process.exit(1) + } + + if (!backups.includes(targetBackup)) { + console.error(`Backup nicht gefunden: ${targetBackup}`) + console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file ') + process.exit(1) + } + + const result = await restoreDataFileBackup(dataPath, targetBackup) + + console.log(`Wiederherstellung abgeschlossen: ${fileName}`) + console.log(` Eingespieltes Backup: ${targetBackup}`) + if (result.backupPath) { + console.log(` Backup des vorherigen Zustands: ${result.backupPath}`) + } +} + +async function main() { + const command = process.argv[2] + + if (!command || command === '--help' || command === '-h') { + printUsage() + return + } + + if (command === 'list') { + await listCommand() + return + } + + if (command === 'restore') { + await restoreCommand() + return + } + + console.error(`Unbekannter Befehl: ${command}`) + printUsage() + process.exit(1) +} + +main().catch((error) => { + console.error('Fehler im Backup/Restore-Skript:', error) + process.exit(1) +}) diff --git a/server/utils/auth.js b/server/utils/auth.js index 5a9c0fd..d101886 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -4,6 +4,7 @@ import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' import { encryptObject, decryptObject } from './encryption.js' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Export migrateUserRoles für Verwendung in anderen Modulen export function migrateUserRoles(user) { @@ -196,7 +197,7 @@ export async function writeUsers(users) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(users, encryptionKey) - await fs.writeFile(USERS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Benutzerdaten:', error) @@ -262,7 +263,7 @@ export async function writeSessions(sessions) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(sessions, encryptionKey) - await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Sessions:', error) diff --git a/server/utils/contact-requests.js b/server/utils/contact-requests.js index a3a034a..39d7465 100644 --- a/server/utils/contact-requests.js +++ b/server/utils/contact-requests.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant, never user input @@ -29,7 +30,7 @@ export async function readContactRequests() { } export async function writeContactRequests(items) { - await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8') + await writeDataFileWithRotation(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), { encoding: 'utf-8' }) } export async function createContactRequest(data) { diff --git a/server/utils/data-file-rotation.js b/server/utils/data-file-rotation.js new file mode 100644 index 0000000..0731beb --- /dev/null +++ b/server/utils/data-file-rotation.js @@ -0,0 +1,125 @@ +import { promises as fs } from 'fs' +import path from 'path' + +const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10) + +function getProjectRoot() { + const cwd = process.cwd() + if (cwd.endsWith('.output')) { + return path.resolve(cwd, '..') + } + return cwd +} + +function getBackupRoot() { + const configured = process.env.DATA_FILE_BACKUP_DIR + if (!configured) { + return path.join(getProjectRoot(), 'backups', 'data-rotation') + } + if (path.isAbsolute(configured)) { + return configured + } + return path.join(getProjectRoot(), configured) +} + +function sanitizeFileKey(filePath) { + const projectRoot = getProjectRoot() + const relative = path.relative(projectRoot, filePath) + const normalized = relative.split(path.sep).join('__') + return normalized.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +function buildBackupName(date = new Date()) { + const randomSuffix = Math.random().toString(36).slice(2, 8) + return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak` +} + +async function ensureDirectory(dirPath) { + await fs.mkdir(dirPath, { recursive: true }) +} + +async function rotateOldBackups(backupDir, maxBackups) { + if (!Number.isFinite(maxBackups) || maxBackups < 1) { + return + } + + const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => []) + const backups = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.bak')) + .map((entry) => entry.name) + .sort() + + const overflowCount = Math.max(0, backups.length - maxBackups) + if (overflowCount === 0) { + return + } + + const toDelete = backups.slice(0, overflowCount) + await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {}))) +} + +export function getBackupDirectoryForDataFile(filePath) { + const resolvedPath = path.resolve(filePath) + return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath)) +} + +export async function listDataFileBackups(filePath) { + const backupDir = getBackupDirectoryForDataFile(filePath) + const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => []) + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.bak')) + .map((entry) => entry.name) + .sort() + .reverse() +} + +export async function writeDataFileWithRotation(filePath, content, { + encoding = 'utf-8', + maxBackups = DEFAULT_MAX_BACKUPS +} = {}) { + const resolvedPath = path.resolve(filePath) + await ensureDirectory(path.dirname(resolvedPath)) + + let existingContent = null + try { + existingContent = await fs.readFile(resolvedPath, encoding) + } catch (error) { + if (error.code !== 'ENOENT') { + throw error + } + } + + if (existingContent !== null && existingContent === content) { + return { + changed: false, + backupPath: null + } + } + + let backupPath = null + if (existingContent !== null) { + const backupDir = getBackupDirectoryForDataFile(resolvedPath) + await ensureDirectory(backupDir) + backupPath = path.join(backupDir, buildBackupName()) + await fs.copyFile(resolvedPath, backupPath) + await rotateOldBackups(backupDir, maxBackups) + } + + const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}` + await fs.writeFile(tmpPath, content, encoding) + await fs.rename(tmpPath, resolvedPath) + + return { + changed: true, + backupPath + } +} + +export async function restoreDataFileBackup(filePath, backupName, options = {}) { + const resolvedPath = path.resolve(filePath) + const backupDir = getBackupDirectoryForDataFile(resolvedPath) + const sourceBackupPath = path.join(backupDir, backupName) + const backupContent = await fs.readFile(sourceBackupPath, 'utf-8') + + return writeDataFileWithRotation(resolvedPath, backupContent, options) +} diff --git a/server/utils/members.js b/server/utils/members.js index 444e240..4e0ea25 100644 --- a/server/utils/members.js +++ b/server/utils/members.js @@ -2,6 +2,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal @@ -192,7 +193,7 @@ export async function writeMembers(members) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(members, encryptionKey) - await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Mitgliederdaten:', error) diff --git a/server/utils/news.js b/server/utils/news.js index c0d13c2..58c9e84 100644 --- a/server/utils/news.js +++ b/server/utils/news.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal @@ -38,7 +39,7 @@ export async function readNews() { // Write news to file export async function writeNews(news) { try { - await fs.writeFile(NEWS_FILE, JSON.stringify(news, null, 2), 'utf-8') + await writeDataFileWithRotation(NEWS_FILE, JSON.stringify(news, null, 2), { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der News:', error) diff --git a/server/utils/newsletter.js b/server/utils/newsletter.js index 68a9441..e46fd1a 100644 --- a/server/utils/newsletter.js +++ b/server/utils/newsletter.js @@ -4,6 +4,7 @@ import { readMembers } from './members.js' import { readUsers } from './auth.js' import { encryptObject, decryptObject } from './encryption.js' import crypto from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.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-subscribers.json'), never user input @@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(subscribers, encryptionKey) - await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8') + await writeDataFileWithRotation(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error) diff --git a/server/utils/termine.js b/server/utils/termine.js index b6a7df2..c879c20 100644 --- a/server/utils/termine.js +++ b/server/utils/termine.js @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { randomUUID } from 'crypto' +import { writeDataFileWithRotation } from './data-file-rotation.js' // Use internal server/data directory for Termine CSV to avoid writing to public/ const getDataPath = (filename) => { @@ -89,7 +90,7 @@ export async function writeTermine(termine) { csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n` } - await fs.writeFile(TERMINE_FILE, csv, 'utf-8') + await writeDataFileWithRotation(TERMINE_FILE, csv, { encoding: 'utf-8' }) return true } catch (error) { console.error('Fehler beim Schreiben der Termine:', error) diff --git a/temp/device-43477-app-after-start.png b/temp/device-43477-app-after-start.png new file mode 100644 index 0000000..caa792e Binary files /dev/null and b/temp/device-43477-app-after-start.png differ diff --git a/temp/device-43477-mannschaften-home.png b/temp/device-43477-mannschaften-home.png new file mode 100644 index 0000000..e6ab93f Binary files /dev/null and b/temp/device-43477-mannschaften-home.png differ diff --git a/tests/data-file-rotation.spec.ts b/tests/data-file-rotation.spec.ts new file mode 100644 index 0000000..341d0f2 --- /dev/null +++ b/tests/data-file-rotation.spec.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import os from 'os' +import path from 'path' +import { promises as fs } from 'fs' +import { + getBackupDirectoryForDataFile, + listDataFileBackups, + restoreDataFileBackup, + writeDataFileWithRotation +} from '../server/utils/data-file-rotation.js' + +describe('Data file rotation utility', () => { + let tempRoot = '' + let backupRoot = '' + let previousBackupDir = '' + let dataFile = '' + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harheimertc-data-rotation-')) + backupRoot = path.join(tempRoot, 'backup-store') + dataFile = path.join(tempRoot, 'server', 'data', 'users.json') + + previousBackupDir = process.env.DATA_FILE_BACKUP_DIR || '' + process.env.DATA_FILE_BACKUP_DIR = backupRoot + }) + + afterEach(async () => { + if (previousBackupDir) { + process.env.DATA_FILE_BACKUP_DIR = previousBackupDir + } else { + delete process.env.DATA_FILE_BACKUP_DIR + } + + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('writes initial file without creating backup', async () => { + const result = await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 }) + + expect(result.changed).toBe(true) + expect(result.backupPath).toBe(null) + + const content = await fs.readFile(dataFile, 'utf-8') + expect(content).toBe('v1') + + const backups = await listDataFileBackups(dataFile) + expect(backups).toEqual([]) + }) + + it('creates backup from previous content on change', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 }) + const result = await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 5 }) + + expect(result.changed).toBe(true) + expect(result.backupPath).toBeTruthy() + + const backups = await listDataFileBackups(dataFile) + expect(backups.length).toBe(1) + + const backupDir = getBackupDirectoryForDataFile(dataFile) + const backupContent = await fs.readFile(path.join(backupDir, backups[0]), 'utf-8') + expect(backupContent).toBe('v1') + + const currentContent = await fs.readFile(dataFile, 'utf-8') + expect(currentContent).toBe('v2') + }) + + it('does not create backup when content is unchanged', async () => { + await writeDataFileWithRotation(dataFile, 'stable', { maxBackups: 5 }) + await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 }) + + const before = await listDataFileBackups(dataFile) + const result = await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 }) + const after = await listDataFileBackups(dataFile) + + expect(result.changed).toBe(false) + expect(result.backupPath).toBe(null) + expect(after).toEqual(before) + }) + + it('rotates old backups and keeps configured maximum', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 2 }) + await writeDataFileWithRotation(dataFile, 'v4', { maxBackups: 2 }) + + const backups = await listDataFileBackups(dataFile) + expect(backups.length).toBe(2) + + const backupDir = getBackupDirectoryForDataFile(dataFile) + const backupContents = await Promise.all( + backups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8')) + ) + + expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3'])) + expect(backupContents).not.toContain('v1') + }) + + it('restores selected backup and keeps pre-restore state as backup', async () => { + await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 10 }) + await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 10 }) + await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 10 }) + + const beforeRestoreBackups = await listDataFileBackups(dataFile) + const backupDir = getBackupDirectoryForDataFile(dataFile) + + const resolved = await Promise.all( + beforeRestoreBackups.map(async (name) => ({ + name, + content: await fs.readFile(path.join(backupDir, name), 'utf-8') + })) + ) + const v1Entry = resolved.find((entry) => entry.content === 'v1') + expect(v1Entry).toBeTruthy() + + const restoreResult = await restoreDataFileBackup(dataFile, v1Entry!.name, { maxBackups: 10 }) + expect(restoreResult.changed).toBe(true) + expect(restoreResult.backupPath).toBeTruthy() + + const restoredContent = await fs.readFile(dataFile, 'utf-8') + expect(restoredContent).toBe('v1') + + const afterRestoreBackups = await listDataFileBackups(dataFile) + const afterContents = await Promise.all( + afterRestoreBackups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8')) + ) + + expect(afterContents).toContain('v3') + }) +})