269 lines
9.3 KiB
JavaScript
269 lines
9.3 KiB
JavaScript
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
|
|
}
|
|
|
|
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}`)
|
|
}
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
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, title, body: userBody, data: payload })
|
|
sent += 1
|
|
validTokens.push(entry)
|
|
} catch (error) {
|
|
failed += 1
|
|
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
|
if (/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message)) === false) {
|
|
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 }
|
|
}
|
|
|
|
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'
|
|
})
|
|
}
|