mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 00:29:14 +00:00
291 lines
10 KiB
JavaScript
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 };
|