166 lines
5.4 KiB
JavaScript
166 lines
5.4 KiB
JavaScript
import crypto from 'crypto'
|
|
import { promises as fs } from 'fs'
|
|
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
|
|
}
|
|
|
|
async function readServiceAccount() {
|
|
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
|
|
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
|
|
}
|
|
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
const raw = await fs.readFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8')
|
|
return JSON.parse(raw)
|
|
}
|
|
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 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
|
|
const tokens = pushTokensForUser(user)
|
|
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) {
|
|
console.error('FCM News-Push fehlgeschlagen:', error.message)
|
|
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
|
validTokens.push(entry)
|
|
} else {
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if (validTokens.length !== tokens.length) {
|
|
user.pushTokens = validTokens
|
|
changed = true
|
|
}
|
|
}
|
|
if (changed) await writeUsers(users)
|
|
return { sent, skipped: false }
|
|
}
|