Files

291 lines
10 KiB
JavaScript

// ═══════════════════════════════════════════════════════════
// Haven: FCM Push Notification Module
// Sends mobile push notifications via Firebase Cloud Messaging.
// Uses only jsonwebtoken (already a Haven dependency); no firebase-admin needed.
//
// Three modes:
// 1. Direct mode: Service account JSON present → sends to FCM API directly
// 2. Relay mode: FCM_RELAY_URL set → forwards to a push relay server
// 3. Default: Neither configured → uses the Haven Global Relay automatically
// ═══════════════════════════════════════════════════════════
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
let serviceAccount = null;
let cachedToken = null;
let cachedTokenExpiry = 0;
let relayUrl = '';
let relayKey = '';
let projectId = '';
// In-memory mirror of the fcm_enabled server setting (Settings → Security → FCM
// Privacy). Kept in sync by setFcmAdminEnabled so the hot message path never
// has to read the database. Default on so nothing breaks before it's loaded.
let adminEnabled = true;
const FCM_SCOPES = 'https://www.googleapis.com/auth/firebase.messaging';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const DEFAULT_RELAY = 'https://us-central1-amni-haven.cloudfunctions.net/sendPush';
const DEFAULT_KEY = 'firebase-notifications-007';
/**
* Initialize FCM. Call once at startup.
* @param {string} dataDir - Haven data directory (to find service account JSON)
* @returns {{ mode: string }} - 'direct', 'relay', or 'disabled'
*/
function initFcm(dataDir) {
const saPath = process.env.FIREBASE_SERVICE_ACCOUNT
|| findServiceAccount(dataDir)
|| findServiceAccount(__dirname);
let via = null;
let mode = 'disabled';
if (saPath && fs.existsSync(saPath)) {
try {
serviceAccount = JSON.parse(fs.readFileSync(saPath, 'utf-8'));
projectId = serviceAccount.project_id;
via = `direct API, project ${projectId}`;
mode = 'direct';
} catch (err) {
console.warn('⚠️ Failed to parse Firebase service account:', err.message);
}
}
if (!serviceAccount) {
// Fall back to relay mode, using the Haven Global Relay by default.
relayUrl = process.env.FCM_RELAY_URL || DEFAULT_RELAY;
relayKey = process.env.FCM_PUSH_KEY || DEFAULT_KEY;
if (relayUrl && relayKey) {
via = relayUrl === DEFAULT_RELAY ? 'Haven Global Relay' : `Custom Relay ${relayUrl}`;
mode = 'relay';
}
}
if (!via) return { mode: 'disabled' };
// The log reflects the effective state, not just the configuration: FCM can
// be wired up here yet still switched off by the admin under Settings >
// Security > FCM Privacy. adminEnabled is loaded from the DB before this runs.
if (adminEnabled) {
console.log(`🔔 FCM enabled via ${via}`);
} else {
console.log(`🔕 FCM configured via ${via}, but turned off in Settings > Security > FCM Privacy`);
}
return { mode };
}
/**
* Look for a service account JSON file in a directory.
*/
function findServiceAccount(dir) {
try {
const parentDir = path.resolve(dir, '..');
for (const d of [dir, parentDir]) {
if (!fs.existsSync(d)) continue;
const files = fs.readdirSync(d).filter(f =>
f.endsWith('.json') && (f.includes('service-account') || f.includes('adminsdk'))
);
if (files.length > 0) return path.join(d, files[0]);
}
} catch { /* folder unreadable: no service account here, so relay mode is used */ }
return null;
}
/**
* Get an OAuth2 access token for the FCM API.
* Caches token for ~55 minutes (tokens last 60 minutes).
*/
async function getAccessToken() {
const now = Math.floor(Date.now() / 1000);
if (cachedToken && now < cachedTokenExpiry) return cachedToken;
const claim = {
iss: serviceAccount.client_email,
scope: FCM_SCOPES,
aud: TOKEN_URL,
iat: now,
exp: now + 3600,
};
const assertion = jwt.sign(claim, serviceAccount.private_key, { algorithm: 'RS256' });
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${assertion}`,
});
if (!resp.ok) {
throw new Error(`OAuth2 token exchange failed: ${resp.status}`);
}
const data = await resp.json();
cachedToken = data.access_token;
cachedTokenExpiry = now + 3300; // refresh 5 min early
return cachedToken;
}
/**
* Send FCM notification directly via the HTTP v1 API.
*/
async function sendDirect(tokens, title, body, dataPayload) {
const accessToken = await getAccessToken();
const url = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
const results = { success: 0, failure: 0, failedTokens: [] };
// FCM v1 API sends one message at a time; fire all in parallel
const promises = tokens.map(async (token) => {
try {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: { title, body },
data: dataPayload,
android: {
priority: 'high',
notification: { channel_id: 'haven_messages' },
},
},
}),
});
if (resp.ok) {
results.success++;
} else {
results.failure++;
const errBody = await resp.json().catch(() => ({}));
const errCode = errBody?.error?.details?.[0]?.errorCode || '';
const errMessage = String(errBody?.error?.message || '');
// A dead or malformed token is dropped. INVALID_ARGUMENT also covers a
// bad message, which would fail for every token alike, so only the
// answer that names the token counts.
const badToken = errCode === 'INVALID_ARGUMENT' && /registration token/i.test(errMessage);
if (errCode === 'UNREGISTERED' || resp.status === 404 || badToken) {
results.failedTokens.push(token);
}
}
} catch {
results.failure++;
}
});
await Promise.all(promises);
return results;
}
/**
* Send FCM notification via an external relay.
*/
async function sendViaRelay(tokens, title, body, dataPayload) {
try {
const resp = await fetch(relayUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-push-key': relayKey,
},
body: JSON.stringify({ tokens, title, body, data: dataPayload }),
});
if (!resp.ok) return { success: 0, failure: tokens.length, failedTokens: [] };
return await resp.json();
} catch (err) {
console.error('FCM relay error:', err.message);
return { success: 0, failure: tokens.length, failedTokens: [] };
}
}
/**
* Send push notifications to the given FCM tokens.
* Automatically uses direct or relay mode based on config.
*
* @param {string[]} tokens - FCM device tokens
* @param {string} title - Notification title
* @param {string} body - Notification body text
* @param {Object} data - Data payload (channelCode, etc.)
* @returns {Promise<{success: number, failure: number, failedTokens: string[]}>}
*/
async function sendFcm(tokens, title, body, data = {}) {
if (!tokens || tokens.length === 0) return { success: 0, failure: 0, failedTokens: [] };
// Convert all data values to strings (FCM requirement)
const dataPayload = Object.fromEntries(
Object.entries(data).map(([k, v]) => [k, String(v)])
);
if (serviceAccount) {
return sendDirect(tokens, title, body, dataPayload);
}
if (relayUrl && relayKey) {
return sendViaRelay(tokens, title, body, dataPayload);
}
return { success: 0, failure: 0, failedTokens: [] };
}
/**
* Check if FCM is available: configured (direct or relay mode) AND not turned
* off by the admin under Settings → Security → FCM Privacy.
*/
function isFcmEnabled() {
return adminEnabled && !!(serviceAccount || (relayUrl && relayKey));
}
/**
* Sync the in-memory admin toggle. Called once at startup from the stored
* setting and again whenever an admin changes it, so isFcmEnabled() stays
* current without touching the database on the message hot path.
*/
function setFcmAdminEnabled(enabled) {
adminEnabled = enabled !== false;
}
/**
* Get the push relay key (for the relay endpoint authentication).
* Returns null if no service account is loaded (relay mode disabled on this server).
*/
function getRelayKey() {
if (!serviceAccount) return null;
return process.env.HAVEN_PUSH_KEY || null;
}
// Real FCM tokens are a few hundred characters. The caps keep one account
// from filling the table, or from making every push fan out to thousands of
// junk tokens; the oldest device drops off first, so a reinstall still
// registers. Returns false when the token is refused.
const FCM_TOKEN_MAX_LENGTH = 4096;
const FCM_TOKENS_PER_USER = 10;
// scope, when given, is which messages this device is pushed: 'all', or
// 'mentions' for DMs, replies and pings only (src/pushMutes.js).
function saveFcmToken(db, userId, token, scope) {
if (typeof token !== 'string' || !token || token.length > FCM_TOKEN_MAX_LENGTH) return false;
db.transaction(() => {
db.prepare('DELETE FROM fcm_tokens WHERE token = ? AND user_id != ?').run(token, userId);
db.prepare(`
INSERT INTO fcm_tokens (user_id, token)
VALUES (?, ?)
ON CONFLICT(user_id, token) DO NOTHING
`).run(userId, token);
if (scope === 'all' || scope === 'mentions') {
db.prepare('UPDATE fcm_tokens SET scope = ? WHERE user_id = ? AND token = ?').run(scope, userId, token);
}
db.prepare(`
DELETE FROM fcm_tokens WHERE user_id = ? AND id NOT IN (
SELECT id FROM fcm_tokens WHERE user_id = ? ORDER BY id DESC LIMIT ?
)
`).run(userId, userId, FCM_TOKENS_PER_USER);
})();
return true;
}
module.exports = { initFcm, sendFcm, isFcmEnabled, setFcmAdminEnabled, getRelayKey, saveFcmToken, FCM_TOKENS_PER_USER, FCM_TOKEN_MAX_LENGTH };