344 lines
12 KiB
JavaScript
Executable File
344 lines
12 KiB
JavaScript
Executable File
import crypto from 'crypto'
|
||
import { promises as fs } from 'fs'
|
||
import path from 'path'
|
||
import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js'
|
||
import { notificationSettingsForUser } from './notification-settings.js'
|
||
|
||
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
||
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||
const tokenCache = { accessToken: null, expiresAt: 0 }
|
||
|
||
function base64Url(input) {
|
||
return Buffer.from(input).toString('base64url')
|
||
}
|
||
|
||
function projectIdFromServiceAccount(serviceAccount) {
|
||
return process.env.FCM_PROJECT_ID || serviceAccount.project_id
|
||
}
|
||
|
||
function serviceAccountCandidatePaths() {
|
||
const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
|
||
const cwd = process.cwd()
|
||
const candidates = []
|
||
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS)
|
||
candidates.push(path.join(cwd, 'server/data', filename))
|
||
candidates.push(path.join(cwd, '../server/data', filename))
|
||
return [...new Set(candidates)]
|
||
}
|
||
|
||
async function readServiceAccount() {
|
||
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
|
||
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
|
||
}
|
||
for (const candidate of serviceAccountCandidatePaths()) {
|
||
try {
|
||
const raw = await fs.readFile(candidate, 'utf8')
|
||
return JSON.parse(raw)
|
||
} catch (error) {
|
||
if (error?.code !== 'ENOENT') {
|
||
console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`)
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function getAccessToken(serviceAccount) {
|
||
if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
|
||
return tokenCache.accessToken
|
||
}
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const assertion = [
|
||
base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
|
||
base64Url(JSON.stringify({
|
||
iss: serviceAccount.client_email,
|
||
scope: FCM_SCOPE,
|
||
aud: TOKEN_URL,
|
||
iat: now,
|
||
exp: now + 3600
|
||
}))
|
||
].join('.')
|
||
const signature = crypto
|
||
.createSign('RSA-SHA256')
|
||
.update(assertion)
|
||
.sign(serviceAccount.private_key, 'base64url')
|
||
const response = await fetch(TOKEN_URL, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({
|
||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
assertion: `${assertion}.${signature}`
|
||
})
|
||
})
|
||
if (!response.ok) {
|
||
throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
|
||
}
|
||
const body = await response.json()
|
||
tokenCache.accessToken = body.access_token
|
||
tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
|
||
return tokenCache.accessToken
|
||
}
|
||
|
||
function pushTokensForUser(user) {
|
||
return Array.isArray(user.pushTokens)
|
||
? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
|
||
: []
|
||
}
|
||
|
||
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
|
||
const normalizedToken = String(token || '').trim()
|
||
if (!normalizedToken) return user
|
||
const now = new Date().toISOString()
|
||
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
||
const next = tokens.filter(entry => entry?.token !== normalizedToken)
|
||
next.push({
|
||
token: normalizedToken,
|
||
platform: String(platform || 'android').slice(0, 30),
|
||
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||
updatedAt: now,
|
||
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||
})
|
||
user.pushTokens = next.slice(-20)
|
||
return user
|
||
}
|
||
|
||
export async function androidPushTokenCountForUser(userId) {
|
||
const user = (await readUsers()).find(entry => entry?.id === userId)
|
||
return pushTokensForUser(user).length
|
||
}
|
||
|
||
async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) {
|
||
const projectId = projectIdFromServiceAccount(serviceAccount)
|
||
if (!projectId) throw new Error('FCM project_id fehlt.')
|
||
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
|
||
method: 'POST',
|
||
headers: {
|
||
authorization: `Bearer ${accessToken}`,
|
||
'content-type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
message: {
|
||
token,
|
||
data,
|
||
android: {
|
||
priority: 'high',
|
||
// 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.
|
||
}
|
||
}
|
||
})
|
||
})
|
||
if (!response.ok) {
|
||
const text = await response.text().catch(() => '')
|
||
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
|
||
}
|
||
}
|
||
|
||
function isStaleFcmTokenError(error) {
|
||
return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || ''))
|
||
}
|
||
|
||
function notificationIdFor(value) {
|
||
return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
|
||
}
|
||
|
||
function userRoles(user) {
|
||
const migrated = migrateUserRoles({ ...(user || {}) })
|
||
return Array.isArray(migrated.roles) ? migrated.roles : []
|
||
}
|
||
|
||
function isVorstandUser(user) {
|
||
const roles = userRoles(user)
|
||
return roles.includes('admin') || roles.includes('vorstand')
|
||
}
|
||
|
||
function normalizePersonName(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.normalize('NFKD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[’'`]/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
}
|
||
|
||
function userMatchesPlayer(user, playerName) {
|
||
const player = normalizePersonName(playerName)
|
||
if (!player) return false
|
||
const candidates = [
|
||
user?.name,
|
||
`${user?.firstName || ''} ${user?.lastName || ''}`.trim()
|
||
].map(normalizePersonName).filter(Boolean)
|
||
return candidates.includes(player)
|
||
}
|
||
|
||
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||
const serviceAccount = await readServiceAccount()
|
||
if (serviceAccount == null) {
|
||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
|
||
}
|
||
const accessToken = await getAccessToken(serviceAccount)
|
||
const users = await readUsers()
|
||
let sent = 0
|
||
let failed = 0
|
||
let removed = 0
|
||
let recipients = 0
|
||
let tokenCount = 0
|
||
let changed = false
|
||
const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')]))
|
||
|
||
for (const user of users) {
|
||
if (isHiddenUser(user)) continue
|
||
const settings = notificationSettingsForUser(user)
|
||
if (predicate && !predicate(user, settings)) continue
|
||
const userBody = String(bodyForUser ? bodyForUser(user, settings) : body || '').slice(0, 240)
|
||
const userData = dataForUser ? dataForUser(user, settings) : {}
|
||
const payload = {
|
||
...baseData,
|
||
...Object.fromEntries(Object.entries(userData || {}).map(([key, value]) => [key, String(value ?? '')])),
|
||
title: String(title || 'Harheimer TC'),
|
||
body: userBody,
|
||
notificationId: String((userData && userData.notificationId) || data.notificationId || notificationIdFor([data.type || 'push', title, userBody].join(':')))
|
||
}
|
||
recipients += 1
|
||
const tokens = pushTokensForUser(user)
|
||
tokenCount += tokens.length
|
||
const validTokens = []
|
||
for (const entry of tokens) {
|
||
try {
|
||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, data: payload })
|
||
sent += 1
|
||
validTokens.push(entry)
|
||
} catch (error) {
|
||
if (isStaleFcmTokenError(error)) {
|
||
removed += 1
|
||
changed = true
|
||
console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message })
|
||
} else {
|
||
failed += 1
|
||
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
||
validTokens.push(entry)
|
||
}
|
||
}
|
||
}
|
||
if (validTokens.length < tokens.length) {
|
||
user.pushTokens = validTokens
|
||
changed = true
|
||
}
|
||
}
|
||
if (changed) await writeUsers(users)
|
||
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) {
|
||
const title = 'Neue News'
|
||
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
|
||
return sendPushToUsers({
|
||
title,
|
||
body,
|
||
data: {
|
||
type: 'news',
|
||
newsId: String(news.id || ''),
|
||
notificationId: notificationIdFor(news.id || Date.now())
|
||
},
|
||
predicate: (_user, settings) => settings.newNews,
|
||
failureLabel: 'FCM News-Push'
|
||
})
|
||
}
|
||
|
||
export async function sendNewEventPush(termin) {
|
||
const title = 'Neuer Termin'
|
||
const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120)
|
||
return sendPushToUsers({
|
||
title,
|
||
body,
|
||
data: {
|
||
type: 'event',
|
||
date: termin?.datum || '',
|
||
notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`)
|
||
},
|
||
predicate: (_user, settings) => settings.newEvents,
|
||
failureLabel: 'FCM Termin-Push'
|
||
})
|
||
}
|
||
|
||
export async function sendNewContactRequestPush(contactRequest) {
|
||
const title = 'Neue Kontaktanfrage'
|
||
const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120)
|
||
return sendPushToUsers({
|
||
title,
|
||
body,
|
||
data: {
|
||
type: 'contact_request',
|
||
notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`)
|
||
},
|
||
predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest,
|
||
failureLabel: 'FCM Kontaktanfrage-Push'
|
||
})
|
||
}
|
||
|
||
export async function sendNewUserRegistrationPush(registration) {
|
||
const title = 'Neue Benutzerregistrierung'
|
||
const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120)
|
||
return sendPushToUsers({
|
||
title,
|
||
body,
|
||
data: {
|
||
type: 'user_registration',
|
||
userId: registration?.id || '',
|
||
notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`)
|
||
},
|
||
predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration,
|
||
failureLabel: 'FCM Registrierungs-Push'
|
||
})
|
||
}
|
||
|
||
export async function sendOwnTtrChangePush(changes = []) {
|
||
const byPlayer = new Map(
|
||
changes
|
||
.filter(change => change?.playerName && Number.isFinite(change?.previousTtr) && Number.isFinite(change?.currentTtr))
|
||
.map(change => [normalizePersonName(change.playerName), change])
|
||
)
|
||
if (!byPlayer.size) return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: false }
|
||
|
||
return sendPushToUsers({
|
||
title: 'TTR-Wert geändert',
|
||
data: { type: 'ttr_change' },
|
||
predicate: (user, settings) => settings.ownTtrChanges && [...byPlayer.values()].some(change => userMatchesPlayer(user, change.playerName)),
|
||
bodyForUser: (user) => {
|
||
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
|
||
return `Dein TTR-Wert hat sich von ${change.previousTtr} auf ${change.currentTtr} geändert.`
|
||
},
|
||
dataForUser: (user) => {
|
||
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
|
||
return {
|
||
previousTtr: change.previousTtr,
|
||
currentTtr: change.currentTtr,
|
||
notificationId: notificationIdFor(`ttr:${change.playerName}:${change.previousTtr}:${change.currentTtr}`)
|
||
}
|
||
},
|
||
failureLabel: 'FCM TTR-Änderungs-Push'
|
||
})
|
||
}
|
||
|
||
export async function sendTestPushToUser(userId) {
|
||
return sendPushToUsers({
|
||
title: 'Harheimer TC: Push-Test',
|
||
body: 'Diese Test-Benachrichtigung wurde erfolgreich vom CMS ausgelöst.',
|
||
data: {
|
||
type: 'test',
|
||
notificationId: notificationIdFor(`test:${userId}:${Date.now()}`)
|
||
},
|
||
predicate: user => user?.id === userId,
|
||
failureLabel: 'FCM Test-Push'
|
||
})
|
||
}
|