Files
harheimertc/server/utils/push-notifications.js
Torsten Schulz (local) 7e533fae49
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
robustere pfad-suche
2026-06-10 16:05:15 +02:00

191 lines
6.2 KiB
JavaScript

import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { readUsers, writeUsers, isHiddenUser } 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
}
async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, 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,
notification: { title, body },
data,
android: {
priority: 'high',
notification: {
channel_id: 'harheimer_tc_updates',
click_action: 'OPEN_NEWS'
}
}
}
})
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
}
}
export async function sendNewNewsPush(news) {
const serviceAccount = await readServiceAccount()
if (!serviceAccount) {
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
return { sent: 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 title = 'Neue News'
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
const data = {
type: 'news',
newsId: String(news.id || ''),
title,
body,
notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
}
for (const user of users) {
if (isHiddenUser(user)) continue
const settings = notificationSettingsForUser(user)
if (!settings.newNews) continue
recipients += 1
const tokens = pushTokensForUser(user)
tokenCount += tokens.length
const validTokens = []
for (const entry of tokens) {
try {
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
sent += 1
validTokens.push(entry)
} catch (error) {
failed += 1
console.error('FCM News-Push fehlgeschlagen:', error.message)
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
validTokens.push(entry)
} else {
removed += 1
changed = true
}
}
}
if (validTokens.length !== tokens.length) {
user.pushTokens = validTokens
changed = true
}
}
if (changed) await writeUsers(users)
return { sent, failed, removed, recipients, tokenCount, skipped: false }
}