diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 76c36e1..82d7a5a 100755 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -78,12 +78,12 @@ val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") { android { namespace = "de.harheimertc" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "de.harheimertc" minSdk = 24 - targetSdk = 35 + targetSdk = 36 versionCode = androidVersionCode versionName = androidVersionName } diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab old mode 100755 new mode 100644 index 8085ef2..1b25271 Binary files a/android-app/app/production/release/app-production-release.aab and b/android-app/app/production/release/app-production-release.aab differ 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 a1e9855..ce19e2a 100755 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -166,6 +166,7 @@ data class MembershipResponse( val success: Boolean = false, val message: String? = null, val downloadUrl: String? = null, + val downloadToken: String? = null, ) data class LoginRequest( val email: String, @@ -660,7 +661,10 @@ interface ApiService { @Streaming @GET - suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response + suspend fun downloadMembershipPdf( + @Url downloadUrl: String, + @retrofit2.http.Header("X-Membership-Download-Token") downloadToken: String? = null, + ): Response @POST("/api/auth/login") suspend fun login(@Body request: LoginRequest): Response diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index e67462a..634d79d 100755 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -16,14 +16,16 @@ import de.harheimertc.R import de.harheimertc.ui.navigation.Destinations object HarheimerNotifications { - const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates" + // A new ID is intentional: Android does not allow an app update to raise + // the importance of an already-created notification channel. + const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates_v2" fun createChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( DEFAULT_CHANNEL_ID, "Harheimer TC", - NotificationManager.IMPORTANCE_DEFAULT, + NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Benachrichtigungen des Harheimer TC" } @@ -47,7 +49,7 @@ object HarheimerNotifications { .setContentTitle(title) .setContentText(message) .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(createContentIntent(context, notificationId, data)) .setAutoCancel(true) .build() diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt index 686703d..590f8d2 100755 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt @@ -9,7 +9,7 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton -data class MembershipDocument(val message: String, val uri: String) +data class MembershipDocument(val message: String, val uri: String? = null) @Singleton class MembershipRepository @Inject constructor( @@ -18,21 +18,33 @@ class MembershipRepository @Inject constructor( ) { suspend fun submit(request: MembershipRequest): Result = runCatching { val response = api.generateMembershipPdf(request) - if (!response.isSuccessful) error("HTTP ${response.code()}") + if (!response.isSuccessful) { + val serverMessage = response.errorBody()?.string() + ?.let { Regex("\\\"(?:statusMessage|message)\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"").find(it)?.groupValues?.getOrNull(1) ?: it } + ?.takeIf { it.isNotBlank() } + error(serverMessage ?: "Der Antrag konnte nicht übermittelt werden (HTTP ${response.code()}).") + } val body = response.body() ?: error("Leere Antwort") if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.") - val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.") - val documentResponse = api.downloadMembershipPdf(downloadUrl) - if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.") - val directory = File(context.cacheDir, "membership").apply { mkdirs() } - val file = File(directory, "beitrittserklaerung.pdf") - documentResponse.body()?.byteStream()?.use { input -> - file.outputStream().use { output -> input.copyTo(output) } - } ?: error("Leere PDF-Antwort") - val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file) + val uri = body.downloadUrl?.let { downloadUrl -> + runCatching { + val documentResponse = api.downloadMembershipPdf(downloadUrl, body.downloadToken) + if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.") + val directory = File(context.cacheDir, "membership").apply { mkdirs() } + val file = File(directory, "beitrittserklaerung.pdf") + documentResponse.body()?.byteStream()?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } ?: error("Leere PDF-Antwort") + FileProvider.getUriForFile(context, "${context.packageName}.files", file).toString() + }.getOrNull() + } MembershipDocument( - message = body.message ?: "Beitrittsformular erfolgreich erstellt.", - uri = uri.toString(), + message = if (uri == null) { + "${body.message ?: "Mitgliedschaftsantrag erfolgreich übermittelt."} Das PDF konnte nicht auf diesem Gerät gespeichert werden." + } else { + body.message ?: "Beitrittsformular erfolgreich erstellt." + }, + uri = uri, ) } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt index c7700c7..359f859 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt @@ -81,7 +81,10 @@ class MembershipViewModel @Inject constructor(private val repository: Membership _state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri) } .onFailure { - _state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.") + _state.value = _state.value.copy( + sending = false, + error = it.message ?: "Beitrittsformular konnte nicht erstellt werden.", + ) } } } diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 6a06329..df9ba17 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=29 -ANDROID_VERSION_NAME=0.9.24 +ANDROID_VERSION_CODE=32 +ANDROID_VERSION_NAME=0.9.27 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/server/api/membership/applications.get.js b/server/api/membership/applications.get.js index b9aa9c2..c819333 100755 --- a/server/api/membership/applications.get.js +++ b/server/api/membership/applications.get.js @@ -2,6 +2,7 @@ import fs from 'fs/promises' import path from 'path' import { requireUserWithAnyRole } from '../../utils/auth.js' import { decryptObject } from '../../utils/encryption.js' +import { getServerDataPath } from '../../utils/paths.js' export default defineEventHandler(async (event) => { try { @@ -17,7 +18,7 @@ export default defineEventHandler(async (event) => { }) } - const dataDir = path.join(process.cwd(), 'server/data/membership-applications') + const dataDir = getServerDataPath('membership-applications') // Prüfen ob Verzeichnis existiert try { diff --git a/server/api/membership/download/[id].get.js b/server/api/membership/download/[id].get.js index 3efdd3a..eed6e61 100755 --- a/server/api/membership/download/[id].get.js +++ b/server/api/membership/download/[id].get.js @@ -1,5 +1,6 @@ import fs from 'fs/promises' import path from 'path' +import { createHmac, timingSafeEqual } from 'crypto' import { getUserFromToken } from '../../../utils/auth.js' import { getServerDataPath } from '../../../utils/paths.js' @@ -47,7 +48,27 @@ export default defineEventHandler(async (event) => { } } - // Prüfen ob es sich um eine aktuelle Session handelt (innerhalb der letzten 24 Stunden) + // Native apps cannot reliably reuse the httpOnly browser cookie that is + // set when the application is created. They receive the same short-lived + // authorization as a signed response token instead. + const signedDownloadToken = getHeader(event, 'x-membership-download-token') + if (signedDownloadToken) { + try { + const [payload, signature] = signedDownloadToken.split('.') + const secret = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' + const expected = createHmac('sha256', secret).update(payload).digest('base64url') + const validSignature = signature && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + const tokenAge = Date.now() - Number(decoded.issuedAt) + if (validSignature && decoded.fileId === fileId && tokenAge >= 0 && tokenAge < 24 * 60 * 60 * 1000) { + isAuthorized = true + } + } catch (_error) { + // Invalid download tokens are treated as unauthorized. + } + } + + // Browser clients continue to use the httpOnly cookie. const downloadToken = getCookie(event, 'download_token') if (downloadToken) { diff --git a/server/api/membership/generate-pdf.post.js b/server/api/membership/generate-pdf.post.js index 32d56e2..8966a78 100755 --- a/server/api/membership/generate-pdf.post.js +++ b/server/api/membership/generate-pdf.post.js @@ -1,11 +1,13 @@ import { exec } from 'child_process' import { promisify } from 'util' +import { createHmac } from 'crypto' import fs from 'fs/promises' import path from 'path' import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js' import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js' import { getProjectPath, getServerDataPath } from '../../utils/paths.js' +import { createMembershipApplication, removeMembershipApplication } from '../../utils/membership-applications.js' // const require = createRequire(import.meta.url) // Nicht verwendet const execAsync = promisify(exec) @@ -310,9 +312,18 @@ function getDataPath(filename) { return getServerDataPath(filename) } +function createMembershipDownloadToken(fileId) { + const issuedAt = Date.now() + const payload = Buffer.from(JSON.stringify({ fileId, issuedAt })).toString('base64url') + const secret = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' + const signature = createHmac('sha256', secret).update(payload).digest('base64url') + return `${payload}.${signature}` +} + // Use central email service export default defineEventHandler(async (event) => { + let application = null try { const body = await readBody(event) @@ -338,6 +349,10 @@ export default defineEventHandler(async (event) => { ...body, isVolljaehrig } + + // Persist the pending application before generating files or sending mail. + // This also makes repeated taps and concurrent requests idempotently fail. + application = await createMembershipApplication(data) // Eindeutige Datei-ID generieren const timestamp = Date.now() @@ -630,9 +645,11 @@ export default defineEventHandler(async (event) => { success: true, message: 'Beitrittsformular erfolgreich aus Template erzeugt und E-Mail gesendet.', downloadUrl: `/api/membership/download/${filename}.pdf`, + downloadToken: createMembershipDownloadToken(`${filename}.pdf`), emailSuccess: emailResult.success, emailMessage: emailResult.message, - usedTemplate: true + usedTemplate: true, + applicationId: application.id } } @@ -690,8 +707,10 @@ export default defineEventHandler(async (event) => { success: true, message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet.', downloadUrl: `/api/membership/download/${filename}.pdf`, + downloadToken: createMembershipDownloadToken(`${filename}.pdf`), emailSuccess: emailResult.success, emailMessage: emailResult.message, + applicationId: application.id } } catch (latexError) { @@ -726,16 +745,22 @@ export default defineEventHandler(async (event) => { success: true, message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet (Fallback-Lösung).', downloadUrl: `/api/membership/download/${fallbackFilename}`, + downloadToken: createMembershipDownloadToken(fallbackFilename), emailSuccess: emailResult.success, emailMessage: emailResult.message, + applicationId: application.id } } } catch (error) { + // A failed generation must not leave a pending application that prevents + // the applicant from trying again. + await removeMembershipApplication(application?.id) console.error('Fehler beim Generieren des PDFs:', error) + if (error?.statusCode) throw error throw createError({ statusCode: 500, statusMessage: 'Fehler beim Generieren des PDFs' }) } -}) \ No newline at end of file +}) diff --git a/server/api/membership/update-status.put.js b/server/api/membership/update-status.put.js index e047f5c..06ee60f 100755 --- a/server/api/membership/update-status.put.js +++ b/server/api/membership/update-status.put.js @@ -1,8 +1,8 @@ import fs from 'fs/promises' -import path from 'path' import { requireUserWithAnyRole } from '../../utils/auth.js' import { decryptObject } from '../../utils/encryption.js' import { saveMember } from '../../utils/members.js' +import { getServerDataPath } from '../../utils/paths.js' export default defineEventHandler(async (event) => { try { @@ -43,9 +43,9 @@ export default defineEventHandler(async (event) => { } // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - const dataDir = path.join(process.cwd(), 'server/data/membership-applications') + const dataDir = getServerDataPath('membership-applications') // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - const filePath = path.join(dataDir, `${id}.json`) + const filePath = getServerDataPath('membership-applications', `${id}.json`) // Antrag laden const fileContent = await fs.readFile(filePath, 'utf8') @@ -67,6 +67,7 @@ export default defineEventHandler(async (event) => { const newMember = { firstName: decryptedData.vorname, lastName: decryptedData.nachname, + geburtsdatum: decryptedData.geburtsdatum, email: decryptedData.email, phone: decryptedData.telefon_privat || decryptedData.telefon_mobil || '', address: `${decryptedData.strasse}, ${decryptedData.plz} ${decryptedData.ort}`, diff --git a/server/utils/members.js b/server/utils/members.js index 063c024..0c56be1 100755 --- a/server/utils/members.js +++ b/server/utils/members.js @@ -221,14 +221,23 @@ export function normalizeDate(dateString) { } // Check for duplicate member based on firstName, lastName, and geburtsdatum -function findDuplicateMember(members, firstName, lastName, geburtsdatum) { - const normalizedFirstName = (firstName || '').trim().toLowerCase() - const normalizedLastName = (lastName || '').trim().toLowerCase() +function normalizeIdentityPart(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .toLowerCase() +} + +export function findDuplicateMember(members, firstName, lastName, geburtsdatum) { + const normalizedFirstName = normalizeIdentityPart(firstName) + const normalizedLastName = normalizeIdentityPart(lastName) const normalizedDate = normalizeDate(geburtsdatum) return members.find(m => { - const mFirstName = (m.firstName || '').trim().toLowerCase() - const mLastName = (m.lastName || '').trim().toLowerCase() + const mFirstName = normalizeIdentityPart(m.firstName) + const mLastName = normalizeIdentityPart(m.lastName) const mDate = normalizeDate(m.geburtsdatum) return mFirstName === normalizedFirstName && @@ -307,4 +316,3 @@ export async function deleteMember(id) { await writeMembers(filtered) return true } - diff --git a/server/utils/membership-applications.js b/server/utils/membership-applications.js new file mode 100644 index 0000000..e0bb21a --- /dev/null +++ b/server/utils/membership-applications.js @@ -0,0 +1,105 @@ +import { createHash, randomUUID } from 'crypto' +import { promises as fs } from 'fs' +import { decryptObject, encryptObject } from './encryption.js' +import { readMembers, findDuplicateMember, normalizeDate } from './members.js' +import { getServerDataPath } from './paths.js' + +const APPLICATIONS_DIR = getServerDataPath('membership-applications') +const LOCKS_DIR = getServerDataPath('membership-application-locks') + +function encryptionKey() { + return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' +} + +function normalizeName(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .toLowerCase() +} + +function identityHash(data) { + const identity = [normalizeName(data.vorname), normalizeName(data.nachname), normalizeDate(data.geburtsdatum)].join('|') + return createHash('sha256').update(identity).digest('hex') +} + +async function withIdentityLock(hash, operation) { + await fs.mkdir(LOCKS_DIR, { recursive: true }) + const lockPath = `${LOCKS_DIR}/${hash}.lock` + let handle + try { + handle = await fs.open(lockPath, 'wx') + } catch (error) { + if (error?.code === 'EEXIST') { + throw createError({ statusCode: 409, statusMessage: 'Für diese Person wird bereits ein Mitgliedschaftsantrag bearbeitet.' }) + } + throw error + } + try { + return await operation() + } finally { + await handle.close().catch(() => {}) + await fs.unlink(lockPath).catch(() => {}) + } +} + +async function findMatchingApplication(data) { + let files = [] + try { + files = await fs.readdir(APPLICATIONS_DIR) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + return null + } + const target = identityHash(data) + for (const file of files.filter(file => file.endsWith('.json'))) { + try { + const application = JSON.parse(await fs.readFile(`${APPLICATIONS_DIR}/${file}`, 'utf8')) + if (application.identityHash === target) return application + if (application.encryptedData) { + const personalData = decryptObject(application.encryptedData, encryptionKey()) + if (identityHash(personalData) === target) return application + } + } catch (error) { + console.warn('Mitgliedschaftsantrag konnte bei der Duplikatprüfung nicht gelesen werden:', { file, message: error.message }) + } + } + return null +} + +export async function createMembershipApplication(data) { + const hash = identityHash(data) + if (!normalizeName(data.vorname) || !normalizeName(data.nachname) || !normalizeDate(data.geburtsdatum)) { + throw createError({ statusCode: 400, statusMessage: 'Vorname, Nachname und ein gültiges Geburtsdatum sind erforderlich.' }) + } + return withIdentityLock(hash, async () => { + const members = await readMembers() + if (findDuplicateMember(members, data.vorname, data.nachname, data.geburtsdatum)) { + throw createError({ statusCode: 409, statusMessage: 'Für diese Person besteht bereits eine Mitgliedschaft.' }) + } + const existing = await findMatchingApplication(data) + if (existing) { + throw createError({ statusCode: 409, statusMessage: 'Für diese Person liegt bereits ein Mitgliedschaftsantrag vor.' }) + } + await fs.mkdir(APPLICATIONS_DIR, { recursive: true }) + const application = { + id: randomUUID(), + timestamp: new Date().toISOString(), + status: 'pending', + identityHash: hash, + metadata: { mitgliedschaftsart: data.mitgliedschaftsart }, + encryptedData: encryptObject(data, encryptionKey()) + } + await fs.writeFile(`${APPLICATIONS_DIR}/${application.id}.json`, `${JSON.stringify(application, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }) + return application + }) +} + +export async function removeMembershipApplication(applicationId) { + if (!applicationId) return + await fs.unlink(`${APPLICATIONS_DIR}/${applicationId}.json`).catch(error => { + if (error?.code !== 'ENOENT') throw error + }) +} diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index ef6e62b..c4b489d 100755 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -114,14 +114,14 @@ async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, body: JSON.stringify({ message: { token, - notification: { title, body }, data, android: { priority: 'high', - notification: { - channel_id: 'harheimer_tc_updates', - click_action: 'OPEN_NEWS' - } + // Keep this as a data message. With a `notification` payload FCM + // renders background messages itself, bypassing our notification + // channel and HarheimerMessagingService. Data messages use the + // same app-controlled channel while the app is foregrounded and + // backgrounded. } } }) @@ -206,7 +206,11 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, bodyF } } if (changed) await writeUsers(users) - return { sent, failed, removed, recipients, tokenCount, skipped: false } + const result = { sent, failed, removed, recipients, tokenCount, skipped: false } + // This makes an absent registration immediately visible in the production + // log without exposing tokens or user data. + console.info('FCM Push Ergebnis:', { failureLabel, ...result }) + return result } export async function sendNewNewsPush(news) { diff --git a/tests/membership-applications.spec.ts b/tests/membership-applications.spec.ts new file mode 100644 index 0000000..2831f74 --- /dev/null +++ b/tests/membership-applications.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'fs' +import os from 'os' +import path from 'path' + +const originalAppRoot = process.env.APP_ROOT +const createdRoots = [] + +afterEach(async () => { + vi.resetModules() + if (originalAppRoot === undefined) delete process.env.APP_ROOT + else process.env.APP_ROOT = originalAppRoot + await Promise.all(createdRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))) +}) + +describe('membership applications', () => { + it('rejects a second pending application with the same name and birth date', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'harheimertc-membership-')) + createdRoots.push(root) + await fs.mkdir(path.join(root, 'server', 'data'), { recursive: true }) + process.env.APP_ROOT = root + + const { createMembershipApplication } = await import('../server/utils/membership-applications.js') + const data = { + vorname: 'Jörg', + nachname: 'Beispiel', + geburtsdatum: '1990-02-03', + mitgliedschaftsart: 'aktiv' + } + + const application = await createMembershipApplication(data) + expect(application.status).toBe('pending') + await expect(createMembershipApplication({ ...data, vorname: ' JÖRG ' })) + .rejects.toMatchObject({ statusCode: 409 }) + }) +})