v0.12.8 FCM bug fix

This commit is contained in:
2026-03-23 19:34:13 -04:00
parent eca93aae28
commit 01f37e60be
25 changed files with 2769 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.7", "version": "0.12.8",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -40,18 +40,35 @@ async function sendPushToUser(schema, userId, payload) {
try { try {
await messaging.send({ await messaging.send({
token: sub.fcm_token, token: sub.fcm_token,
// Top-level notification ensures FCM/Chrome can display even if the SW
// onBackgroundMessage handler has trouble — mirrors the working fcm-app pattern.
notification: {
title: payload.title || 'New Message',
body: payload.body || '',
},
// Extra fields for SW click-routing (url, groupId)
data: { data: {
title: payload.title || 'New Message',
body: payload.body || '',
url: payload.url || '/', url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '', groupId: payload.groupId ? String(payload.groupId) : '',
}, },
android: { priority: 'high' }, android: {
priority: 'high',
notification: { sound: 'default' },
},
apns: { apns: {
headers: { 'apns-priority': '10' }, headers: { 'apns-priority': '10' },
payload: { aps: { contentAvailable: true } }, payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
},
webpush: {
headers: { Urgency: 'high' },
notification: {
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message',
renotify: true,
},
fcm_options: { link: payload.url || '/' },
}, },
webpush: { headers: { Urgency: 'high' } },
}); });
} catch (err) { } catch (err) {
// Remove stale tokens // Remove stale tokens
@@ -117,9 +134,9 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => {
}); });
// Send a test push to the requesting user's own device — for diagnosing FCM setup. // Send a test push to the requesting user's own device — for diagnosing FCM setup.
// mode=data (default): data-only message handled by the service worker onBackgroundMessage. // mode=notification (default): notification+data message — same path as real messages.
// mode=browser: webpush.notification message handled by Chrome directly (bypasses SW). // mode=browser: webpush.notification only — Chrome shows it directly, SW not involved.
// Use mode=browser to check if FCM delivery itself works when the SW is not involved. // Use mode=browser to verify FCM delivery works independently of the service worker.
router.post('/test', authMiddleware, async (req, res) => { router.post('/test', authMiddleware, async (req, res) => {
try { try {
const subs = await query(req.schema, const subs = await query(req.schema,
@@ -137,38 +154,46 @@ router.post('/test', authMiddleware, async (req, res) => {
return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' }); return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' });
} }
const mode = req.query.mode === 'browser' ? 'browser' : 'data'; const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
const results = []; const results = [];
for (const sub of subs) { for (const sub of subs) {
try { try {
const message = { const message = {
token: sub.fcm_token, token: sub.fcm_token,
android: { priority: 'high' }, android: {
priority: 'high',
notification: { sound: 'default' },
},
apns: { apns: {
headers: { 'apns-priority': '10' }, headers: { 'apns-priority': '10' },
payload: { aps: { contentAvailable: true } }, payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
},
webpush: {
headers: { Urgency: 'high' },
notification: {
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
tag: 'rosterchirp-test',
},
}, },
webpush: { headers: { Urgency: 'high' } },
}; };
if (mode === 'browser') { if (mode === 'browser') {
// Chrome displays the notification directly — onBackgroundMessage does NOT fire. // Chrome displays the notification directly — onBackgroundMessage does NOT fire.
// Use this to verify FCM delivery works independently of the service worker. // Use this to verify FCM delivery works independently of the service worker.
message.webpush.notification = { message.webpush.notification.title = 'RosterChirp Test (browser)';
title: 'RosterChirp Test (browser)', message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.';
body: 'FCM delivery confirmed — Chrome handled this directly.',
icon: '/icons/icon-192.png',
};
message.webpush.fcm_options = { link: '/' }; message.webpush.fcm_options = { link: '/' };
} else { } else {
// data-only — service worker onBackgroundMessage must show the notification. // notification+data — same structure as real messages.
message.data = { // SW onBackgroundMessage fires and shows the notification.
title: 'RosterChirp Test', message.notification = {
body: 'Push notifications are working!', title: 'RosterChirp Test',
url: '/', body: 'Push notifications are working!',
groupId: '',
}; };
message.data = { url: '/', groupId: '' };
message.webpush.fcm_options = { link: '/' };
} }
await messaging.send(message); await messaging.send(message);
@@ -183,4 +208,22 @@ router.post('/test', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Debug endpoint (admin-only) — lists all FCM subscriptions for this schema
router.get('/debug', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
try {
const subs = await query(req.schema, `
SELECT ps.id, ps.user_id, ps.device, ps.fcm_token,
u.name, u.email
FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id
WHERE ps.fcm_token IS NOT NULL
ORDER BY u.name, ps.device
`);
const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT);
const firebaseAdminReady = !!getMessaging();
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = { router, sendPushToUser }; module.exports = { router, sendPushToUser };

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.7}" VERSION="${1:-0.12.8}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

15
fcm-app/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
.env.*.local
.nyc_output
coverage
.vscode
.idea
*.log
ssl/
icon-*.png

18
fcm-app/.env Normal file
View File

@@ -0,0 +1,18 @@
# Firebase Configuration
FIREBASE_PROJECT_ID=fcmtest-push
FIREBASE_PRIVATE_KEY_ID=ac38f0122d21b6db2e7cfae4ed2120d848afcb13
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7S59WBylwnzgq\nYUpbwj4vzoLa6MtC7K/ZrB2Uxj1QuqdbnMsFid9RkWs+z86FUH/DgGyABnhhuBxO\nK8yQ+f1WR6deM7v1xFLrmYVDLk/7VGNGtn/xmQ7yjJPLFLqNplPWxjz8StJDiRRh\nFjPewGFrk/afDy0garsJTP6tK1IRGIf/dvIdBiCHQ1xpmWwkNDb1xNFSWx3JpN9m\nEbsMZBo5Af2jL044Z4jLEO+y32freiRoZBG4KG6Jb4+xo2qwjxFATmychpc9xEsf\nrMyOaV7omuhqOmjK3PfSotZnYyYAat8kerATe/EZsRtlTh1UHsiN+1FNy/RPV5s8\nTFYWf7a/AgMBAAECggEAJ7Ce01wxK+yRumljmI5RH1Bj6n/qkwQVP8t5eU2JMNJd\nJMzVORc+e8qVL3paCWZFrOhKFddJK2wYk3g0oYRYazBEB3JvImW4LLUbyGDIEjqP\nzyxdcJU+1ad0qlR6NApLOfhIdC5m4GjsKKbL1yhtfJ6eZJaSuYvkltP6JDhJ69Uq\nLdtA2dA5RGr1W1I8G3Yw4tNw5ImrfxbD7sO1y7A2aI5ZRL4/fOK0QCjbu8dznqPg\n8qT4dqabIRWTdM70ixEqfojQwNmL1w4wVajX470jn8iJZau0QMpJVfm2PtBxzXcM\nuQU+kP6b7BrFvKJ4LD0UOweiDQncfnKiNamMZKQgAQKBgQDcobi+lhkYxekvztq/\nv0d3RqgpmnABg1dPvNYbFV1WPjzCy/Pv87HFROb0LA/xNQKjA+2ss+LDEZXgSRuV\n7ovEQ2Zib/TyN10ihYGpIbXlbxz9rEtsatIuynKvYFlWm/v1S5LnPkCXlkHLi+cO\n2Z6DniGjCLqB4w5ZqkYzWVnSfwKBgQDZUdh5VRAR/ge1Vi5QtpQKuaZRvxjS+GZH\nmJNuIfm/+9zKakOMXgieT1wyTFr6I7955h967BrfO/djtvAQca+7l68hlyTgS4bf\n+nEVCTd3wwAbcEXOubpgnyLzQeaztRTFkcpyTZ2eVGraoAjijsElOtbJBbu9xaqS\nOoH4Adt7wQKBgQDNppSMWV41QCx2Goq9li6oGB0hAkoKrwEQWwT7I7PncoWyUOck\nr3LxXKMlz3hgrbeyeTPt+ZKRnu+jqqFi5II0w1pIwPCBYWeXiPftzXU90Y8lSJbZ\nDMyzPpMds2Iyn5x/7RyWHOmaIj1b3CDYL7JYHmpeDAHElf7HRza+IDfgQwKBgBTQ\nfwBYAlsGzqwynesDIbjJQUHRIMqMGhe/aFeDD42wzNviQ6f9Faw8A6OZppkQtXUy\nck9ur8Az2SUGz4VzrhY0mASKmnCVK0zmitAt+s8QsUDvhvAe39gDRfCwni0WKfAm\nX5KFFpSklztrWo6Ah8VOFmZYkzvA4+5vhiU/4ErBAoGAboI2WX/JNd8A5KQgRRpT\n5RkNLbhgg1TaBBEdfCkpuCJbpghAnfpvg/2lTtbLJ7SbAijmldrT5nNbhVNxAgYM\nZgOcoZJPBGi1AB1HzlkcGO/C9/H1tnEBB6ECbQ3yaz0n8TLUuJqHGwsomJJVPACT\n2FSNbfQ0TqCs1ba+Hx9iQBQ=\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@fcmtest-push.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=103917424542871804597
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40fcmtest-push.iam.gserviceaccount.com
# VAPID Key for Web Push
VAPID_KEY=BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw
# Server Configuration
PORT=3000
NODE_ENV=production
TZ=America/Toronto

15
fcm-app/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Firebase Configuration
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=your-client-id
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-...%40your-project-id.iam.gserviceaccount.com
# Server Configuration
PORT=3000
NODE_ENV=production
TZ=America/Toronto

33
fcm-app/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Use Node.js 18 LTS
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files first (for better layer caching)
COPY package*.json ./
# Install dependencies and wget
RUN npm install --omit=dev && apk add --no-cache wget
# Create non-root user and a writable data directory before copying app code
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
mkdir -p /app/data && \
chown nodejs:nodejs /app/data
# Copy application code (exclude node_modules via .dockerignore)
COPY --chown=nodejs:nodejs . .
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
# Start the application
CMD ["npm", "start"]

View File

@@ -0,0 +1,311 @@
# FCM PWA Implementation Notes
_Reference for applying FCM fixes to other projects_
---
## Part 1 — Guide Key Points (fcm_details.txt)
### How FCM works (the correct flow)
1. User grants notification permission
2. Firebase generates a unique FCM token for the device
3. Token is stored on your server for targeting
4. Server sends push requests to Firebase
5. Firebase delivers notifications to the device
6. Service worker handles display and click interactions
### Common vibe-coding failures with FCM
**1. Service worker confusion**
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
**2. Deprecated API usage**
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
```javascript
const token = await messaging.getToken({
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
```
**3. Token generation without durable storage**
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
**4. Poor permission flow**
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
**5. Missing notificationclick handler**
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
**6. Silent failures**
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
**7. iOS blind spots**
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
### Correct `getToken()` pattern (from guide)
```javascript
// Register SW first, then pass it directly to getToken
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
const token = await getToken(messaging, {
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
```
### Correct `firebase-messaging-sw.js` pattern (from guide)
```javascript
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({ /* config */ });
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
self.registration.showNotification(payload.notification.title, {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-notification',
data: payload.data
});
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow('/');
})
);
});
```
---
## Part 2 — Code Fixes Applied to fcm-app
### app.js fixes
**Fix: `showUserInfo()` missing**
Function was called on login and session restore but never defined — crashed immediately on login.
```javascript
function showUserInfo() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}
```
**Fix: `setupApp()` wrong element IDs**
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
```javascript
// Wrong
document.getElementById('sendNotification').addEventListener('click', sendNotification);
// Fixed
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
// Also added id="logoutBtn" to the logout button in index.html
```
**Fix: `logout()` not clearing localStorage**
Session was restored on next page load even after logout.
```javascript
function logout() {
currentUser = null;
fcmToken = null;
localStorage.removeItem('currentUser'); // was missing
// ...
}
```
**Fix: Race condition in messaging initialization**
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
```javascript
let initPromise = null;
function initializeFirebase() {
if (initPromise) return initPromise;
initPromise = navigator.serviceWorker.register('/sw.js')
.then((registration) => {
swRegistration = registration;
messaging = firebase.messaging();
})
.catch((error) => { initPromise = null; throw error; });
return initPromise;
}
// In login():
await initializeFirebase(); // ensures messaging is ready before getToken()
```
**Fix: `deleteToken()` invalidating tokens on every page load**
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
**Fix: Session restore without re-registering token**
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
```javascript
// In setupApp(), after restoring session:
if (Notification.permission === 'granted') {
initializeFirebase()
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
.then(token => { if (token) return registerToken(currentUser, token); })
.catch(err => console.error('Token refresh on session restore failed:', err));
}
```
**Fix: Deprecated VAPID/SW API replaced**
```javascript
// Removed (deprecated):
messaging.usePublicVapidKey(VAPID_KEY);
messaging.useServiceWorker(registration);
const token = await messaging.getToken();
// Replaced with:
const VAPID_KEY = 'your-vapid-key';
let swRegistration = null;
// swRegistration set inside initializeFirebase() .then()
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
```
**Fix: Null token guard**
`getToken()` can return null — passing null to the server produced a confusing 400 error.
```javascript
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
```
**Fix: Error message included server response**
```javascript
// Before: throw new Error('Failed to register token');
// After:
throw new Error(`Server returned ${response.status}: ${errorText}`);
```
**Fix: Duplicate foreground message handlers**
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
```javascript
let foregroundHandlerSetup = false;
function handleForegroundMessages() {
if (foregroundHandlerSetup) return;
foregroundHandlerSetup = true;
messaging.onMessage(/* ... */);
}
```
**Fix: `login()` event.preventDefault() crash**
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
```javascript
async function login(event) {
if (event) event.preventDefault(); // guard added
```
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
**Fix: `notificationclick` handler added to `sw.js`**
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
```javascript
// Removed from urlsToCache:
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
```
### server.js fixes
**Fix: `icon`/`badge`/`tag` in wrong notification object**
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
```javascript
// Wrong:
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
// Fixed:
notification: { title, body },
webpush: {
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
// ...
}
```
**Fix: `saveTokens()` in route handler not crash-safe**
```javascript
try {
saveTokens();
} catch (saveError) {
console.error('Failed to persist tokens to disk:', saveError);
}
```
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
```javascript
setInterval(() => {
try { saveTokens(); }
catch (error) { console.error('Auto-save tokens failed:', error); }
}, 30000);
```
---
## Part 3 — Docker / Infrastructure Fixes
### Root cause of "no other users" bug
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
2. This propagated out of `setInterval` as an uncaught exception
3. Node.js exited the process
4. Docker restarted the container with empty state
5. Tokens were never on disk, so restart = all tokens lost
### Dockerfile fix
```dockerfile
# Create non-root user AND a writable data directory (while still root)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
mkdir -p /app/data && \
chown nodejs:nodejs /app/data
```
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
### docker-compose.yml fix
```yaml
services:
your-app:
volumes:
- app_data:/app/data # named volume survives container rebuilds
volumes:
app_data:
```
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
### server.js path fix
```javascript
// Changed from:
const TOKENS_FILE = './tokens.json';
// To:
const TOKENS_FILE = './data/tokens.json';
```
---
## Checklist for applying to another project
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
- [ ] `notificationclick` handler present in service worker
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
- [ ] `deleteToken()` is NOT called on page load
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
- [ ] Null/empty token check before sending to server
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
- [ ] Docker: data directory created with correct user ownership in Dockerfile
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
- [ ] Duplicate foreground message handler registration is guarded

209
fcm-app/README.md Normal file
View File

@@ -0,0 +1,209 @@
# FCM Test PWA
A Progressive Web App for testing Firebase Cloud Messaging (FCM) notifications across desktop and mobile devices.
## Features
- PWA with install capability
- Firebase Cloud Messaging integration
- Multi-user support (pwau1, pwau2, pwau3)
- SSL/HTTPS ready
- Docker deployment
- Real-time notifications
## Quick Start
### 1. Firebase Setup
1. **Create Firebase Project**
- Go to [Firebase Console](https://console.firebase.google.com/)
- Click "Add project"
- Enter project name (e.g., "fcm-test-pwa")
- Enable Google Analytics (optional)
- Click "Create project"
2. **Enable Cloud Messaging**
- In your project dashboard, go to "Build" → "Cloud Messaging"
- Click "Get started"
- Cloud Messaging is now enabled for your project
3. **Get Firebase Configuration**
- Go to Project Settings (⚙️ icon)
- Under "Your apps", click "Web app" (</> icon)
- Register app with nickname "FCM Test PWA"
- Copy the Firebase config object (you'll need this later)
4. **Generate Service Account Key**
- In Project Settings, go to "Service accounts"
- Click "Generate new private key"
- Save the JSON file (you'll need this for the server)
5. **Get Web Push Certificate**
- In Cloud Messaging settings, click "Web Push certificates"
- Generate and save the key pair
### 2. Server Configuration
1. **Copy environment template**
```bash
cp .env.example .env
```
2. **Update .env file** with your Firebase credentials:
```env
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=your-client-id
# ... other fields from service account JSON
```
3. **Update Firebase config in client files**:
- Edit `public/app.js` - replace Firebase config
- Edit `public/sw.js` - replace Firebase config
### 3. Local Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
Open http://localhost:3000 in your browser.
### 4. Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
```
## User Accounts
| Username | Password | Purpose |
|----------|----------|---------|
| pwau1 | test123 | Desktop user |
| pwau2 | test123 | Mobile user 1 |
| pwau3 | test123 | Mobile user 2 |
## Usage
1. **Install as PWA**
- Open the app in Chrome/Firefox
- Click the install icon in the address bar
- Install as a desktop app
2. **Enable Notifications**
- Login with any user account
- Grant notification permissions when prompted
- FCM token will be automatically registered
3. **Send Notifications**
- Click "Send Notification" button
- All other logged-in users will receive the notification
- Check both desktop and mobile devices
## Deployment on Ubuntu LXC + HAProxy
### Prerequisites
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
### SSL Certificate Setup
```bash
# Create SSL directory
mkdir -p ssl
# Generate self-signed certificate (for testing)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com"
# OR use Let's Encrypt for production
sudo apt install certbot
sudo certbot certonly --standalone -d your-domain.com
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem
```
### HAProxy Configuration
Add to your `/etc/haproxy/haproxy.cfg`:
```haproxy
frontend fcm_test_frontend
bind *:80
bind *:443 ssl crt /etc/ssl/certs/your-cert.pem
redirect scheme https if !{ ssl_fc }
default_backend fcm_test_backend
backend fcm_test_backend
balance roundrobin
server fcm_test 127.0.0.1:3000 check
```
### Deploy
```bash
# Clone and setup
git clone <your-repo>
cd fcm-test-pwa
cp .env.example .env
# Edit .env with your Firebase config
# Deploy
docker-compose up -d
# Check status
docker-compose ps
docker-compose logs
```
## Testing
1. **Desktop Testing**
- Open app in Chrome
- Install as PWA
- Login as pwau1
- Send test notifications
2. **Mobile Testing**
- Open app on mobile browsers
- Install as PWA
- Login as pwau2 and pwau3 on different devices
- Test cross-device notifications
## Troubleshooting
- **Notifications not working**: Check Firebase configuration and service worker
- **PWA not installing**: Ensure site is served over HTTPS
- **Docker issues**: Check logs with `docker-compose logs`
- **HAProxy issues**: Verify SSL certificates and backend connectivity
## Security Notes
- Change default passwords in production
- Use proper SSL certificates
- Implement rate limiting for notifications
- Consider using a database for token storage in production

View File

@@ -0,0 +1,22 @@
services:
fcm-test-app:
build: .
ports:
- "3066:3000"
environment:
- NODE_ENV=production
- TZ=${TZ:-UTC}
env_file:
- .env
volumes:
- fcm_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
fcm_data:

1013
fcm-app/fcm_details.txt Normal file

File diff suppressed because it is too large Load Diff

57
fcm-app/nginx.conf Normal file
View File

@@ -0,0 +1,57 @@
events {
worker_connections 1024;
}
http {
upstream app {
server fcm-test-app:3000;
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PWA headers
add_header Service-Worker-Allowed "/";
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Serve static files directly
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|json|webmanifest)$ {
proxy_pass http://app;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

23
fcm-app/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "fcm-test-pwa",
"version": "1.0.0",
"description": "PWA for testing Firebase Cloud Messaging",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"firebase": "^10.7.1",
"firebase-admin": "^12.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"keywords": ["pwa", "fcm", "firebase", "notifications"],
"author": "",
"license": "MIT"
}

334
fcm-app/public/app.js Normal file
View File

@@ -0,0 +1,334 @@
// Load Firebase SDK immediately
const script1 = document.createElement('script');
script1.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js';
script2.onload = () => {
// Initialize Firebase immediately
initializeFirebase();
console.log('Firebase SDK and initialization complete');
// Now that Firebase is ready, set up the app
setupApp();
};
document.head.appendChild(script2);
};
document.head.appendChild(script1);
// Global variables
let currentUser = null;
let fcmToken = null;
let messaging = null;
let swRegistration = null;
let initPromise = null;
let foregroundHandlerSetup = false;
const VAPID_KEY = 'BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw';
// Simple user authentication
const users = {
'pwau1': { password: 'test123', name: 'Desktop User' },
'pwau2': { password: 'test123', name: 'Mobile User 1' },
'pwau3': { password: 'test123', name: 'Mobile User 2' }
};
// Initialize Firebase — returns a promise that resolves when messaging is ready
function initializeFirebase() {
if (initPromise) return initPromise;
const firebaseConfig = {
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
};
if (firebase.apps.length === 0) {
firebase.initializeApp(firebaseConfig);
console.log('Firebase app initialized');
}
initPromise = navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration);
swRegistration = registration;
messaging = firebase.messaging();
console.log('Firebase messaging initialized successfully');
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
initPromise = null;
throw error;
});
return initPromise;
}
// Show user info panel and hide login form
function showUserInfo() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}
// Setup app after Firebase is ready
function setupApp() {
// Set up event listeners
document.getElementById('loginForm').addEventListener('submit', login);
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
document.getElementById('logoutBtn').addEventListener('click', logout);
// Restore session and re-register FCM token if notifications were already granted
const savedUser = localStorage.getItem('currentUser');
if (savedUser) {
currentUser = savedUser;
showUserInfo();
if (Notification.permission === 'granted') {
initializeFirebase()
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
.then(token => { if (token) return registerToken(currentUser, token); })
.catch(err => console.error('Token refresh on session restore failed:', err));
}
}
}
// Request notification permission and get FCM token
async function requestNotificationPermission() {
try {
console.log('Requesting notification permission...');
const permission = await Notification.requestPermission();
console.log('Permission result:', permission);
if (permission === 'granted') {
console.log('Notification permission granted.');
showStatus('Getting FCM token...', 'info');
try {
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
console.log('FCM Token generated:', token);
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
fcmToken = token;
// Send token to server
await registerToken(currentUser, token);
showStatus('Notifications enabled successfully!', 'success');
} catch (tokenError) {
console.error('Error getting FCM token:', tokenError);
showStatus('Failed to get FCM token: ' + tokenError.message, 'error');
}
} else {
console.log('Notification permission denied.');
showStatus('Notification permission denied.', 'error');
}
} catch (error) {
console.error('Error requesting notification permission:', error);
showStatus('Failed to enable notifications: ' + error.message, 'error');
}
}
// Register FCM token with server
async function registerToken(username, token) {
try {
console.log('Attempting to register token:', { username, token: token.substring(0, 20) + '...' });
const response = await fetch('/register-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, token })
});
console.log('Registration response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Server returned ${response.status}: ${errorText}`);
}
const result = await response.json();
console.log('Token registered successfully:', result);
showStatus(`Token registered for ${username}`, 'success');
} catch (error) {
console.error('Error registering token:', error);
showStatus('Failed to register token with server: ' + error.message, 'error');
}
}
// Handle foreground messages (guard against duplicate registration)
function handleForegroundMessages() {
if (foregroundHandlerSetup) return;
foregroundHandlerSetup = true;
messaging.onMessage(function(payload) {
console.log('Received foreground message: ', payload);
// Show notification in foreground
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png'
};
new Notification(notificationTitle, notificationOptions);
showStatus(`New notification: ${payload.notification.body}`, 'info');
});
}
// Login function
async function login(event) {
if (event) event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!users[username] || users[username].password !== password) {
showStatus('Invalid username or password', 'error');
return;
}
currentUser = username;
localStorage.setItem('currentUser', username);
showUserInfo();
showStatus(`Logged in as ${users[username].name}`, 'success');
// Initialize Firebase and request notifications
if (typeof firebase !== 'undefined') {
await initializeFirebase();
await requestNotificationPermission();
handleForegroundMessages();
} else {
showStatus('Firebase not loaded. Please check your connection.', 'error');
}
}
// Logout function
function logout() {
currentUser = null;
fcmToken = null;
localStorage.removeItem('currentUser');
document.getElementById('loginForm').style.display = 'block';
document.getElementById('userInfo').style.display = 'none';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
showStatus('Logged out successfully.', 'info');
}
// Send notification function
async function sendNotification() {
if (!currentUser) {
showStatus('Please login first.', 'error');
return;
}
try {
// First check registered users
const usersResponse = await fetch('/users');
const users = await usersResponse.json();
console.log('Registered users:', users);
const response = await fetch('/send-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fromUser: currentUser,
title: 'Test Notification',
body: `Notification sent from ${currentUser} at ${new Date().toLocaleTimeString()}`
})
});
if (!response.ok) {
throw new Error('Failed to send notification');
}
const result = await response.json();
console.log('Send result:', result);
if (result.recipients === 0) {
showStatus('No other users have registered tokens. Open the app on other devices and enable notifications.', 'error');
} else {
showStatus(`Notification sent to ${result.recipients} user(s)!`, 'success');
}
} catch (error) {
console.error('Error sending notification:', error);
showStatus('Failed to send notification.', 'error');
}
}
// Show status message
function showStatus(message, type) {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 5000);
}
// Register service worker and handle PWA installation
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
// Handle PWA installation
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
console.log('beforeinstallprompt fired');
e.preventDefault();
deferredPrompt = e;
// Show install button or banner
showInstallButton();
});
function showInstallButton() {
const installBtn = document.createElement('button');
installBtn.textContent = 'Install App';
installBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
z-index: 1000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
installBtn.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to the install prompt: ${outcome}`);
deferredPrompt = null;
installBtn.remove();
}
});
document.body.appendChild(installBtn);
}
})
.catch(function(error) {
console.log('ServiceWorker registration failed: ', error);
});
});
}

View File

@@ -0,0 +1,48 @@
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage(function(payload) {
console.log('Received background message:', payload);
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test',
data: payload.data
};
self.registration.showNotification(notificationTitle, notificationOptions);
});
self.addEventListener('notificationclick', function(event) {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});

BIN
fcm-app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
fcm-app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

111
fcm-app/public/index.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FCM Test PWA</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2196F3">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 400px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-form {
display: block;
}
.user-info {
display: none;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px;
background-color: #2196F3;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin: 10px 0;
}
button:hover {
background-color: #1976D2;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
text-align: center;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.user-display {
background-color: #e3f2fd;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
text-align: center;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h1>FCM Test PWA</h1>
<div id="status" class="status" style="display: none;"></div>
<div id="loginForm" class="login-form">
<h2>Login</h2>
<input type="text" id="username" placeholder="Username (pwau1, pwau2, or pwau3)" required>
<input type="password" id="password" placeholder="Password" required>
<button onclick="login()">Login</button>
</div>
<div id="userInfo" class="user-info">
<div class="user-display">
Logged in as: <span id="currentUser"></span>
</div>
<button id="sendNotificationBtn" onclick="sendNotification()">Send Notification</button>
<button id="logoutBtn" onclick="logout()">Logout</button>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "FCM Test PWA",
"short_name": "FCM Test",
"description": "PWA for testing Firebase Cloud Messaging",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"purpose": "any maskable",
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"purpose": "any maskable",
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"categories": ["utilities", "productivity"],
"lang": "en-US"
}

82
fcm-app/public/sw.js Normal file
View File

@@ -0,0 +1,82 @@
const CACHE_NAME = 'fcm-test-pwa-v1';
const urlsToCache = [
'/',
'/index.html',
'/app.js',
'/manifest.json'
];
// Install event
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
return cache.addAll(urlsToCache);
})
);
});
// Fetch event
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
// Background sync for FCM
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
// Initialize Firebase in service worker
firebase.initializeApp({
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
});
const messaging = firebase.messaging();
// Handle notification clicks
self.addEventListener('notificationclick', function(event) {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});
// Handle background messages
messaging.onBackgroundMessage(function(payload) {
console.log('Received background message ', payload);
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test'
};
return self.registration.showNotification(notificationTitle, notificationOptions);
});

244
fcm-app/server.js Normal file
View File

@@ -0,0 +1,244 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const cors = require('cors');
const admin = require('firebase-admin');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// In-memory storage for FCM tokens (in production, use a database)
const userTokens = new Map();
// Load tokens from file on startup (for persistence)
const fs = require('fs');
const TOKENS_FILE = './data/tokens.json';
function loadTokens() {
try {
if (fs.existsSync(TOKENS_FILE)) {
const data = fs.readFileSync(TOKENS_FILE, 'utf8');
const tokens = JSON.parse(data);
for (const [user, tokenArray] of Object.entries(tokens)) {
userTokens.set(user, new Set(tokenArray));
}
console.log(`Loaded tokens for ${userTokens.size} users from file`);
}
} catch (error) {
console.log('No existing tokens file found, starting fresh');
}
}
function saveTokens() {
const tokens = {};
for (const [user, tokenSet] of userTokens.entries()) {
tokens[user] = Array.from(tokenSet);
}
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
}
// Load existing tokens on startup
loadTokens();
// Auto-save tokens every 30 seconds
setInterval(() => {
try {
saveTokens();
} catch (error) {
console.error('Auto-save tokens failed:', error);
}
}, 30000);
// Initialize Firebase Admin
if (process.env.FIREBASE_PRIVATE_KEY) {
const serviceAccount = {
projectId: process.env.FIREBASE_PROJECT_ID,
privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
clientId: process.env.FIREBASE_CLIENT_ID,
authUri: process.env.FIREBASE_AUTH_URI,
tokenUri: process.env.FIREBASE_TOKEN_URI,
authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
clientC509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL
};
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
console.log('Firebase Admin initialized successfully');
} else {
console.log('Firebase Admin not configured. Please set up .env file');
}
// Routes
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Register FCM token
app.post('/register-token', (req, res) => {
const { username, token } = req.body;
console.log(`Token registration request:`, { username, token: token?.substring(0, 20) + '...' });
if (!username || !token) {
console.log('Token registration failed: missing username or token');
return res.status(400).json({ error: 'Username and token are required' });
}
// Store token for user
if (!userTokens.has(username)) {
userTokens.set(username, new Set());
}
const userTokenSet = userTokens.get(username);
if (userTokenSet.has(token)) {
console.log(`Token already registered for user: ${username}`);
} else {
userTokenSet.add(token);
console.log(`New token registered for user: ${username}`);
// Save immediately after new registration
try {
saveTokens();
} catch (saveError) {
console.error('Failed to persist tokens to disk:', saveError);
}
}
console.log(`Total tokens for ${username}: ${userTokenSet.size}`);
console.log(`Total registered users: ${userTokens.size}`);
res.json({ success: true, message: 'Token registered successfully' });
});
// Send notification to all other users
app.post('/send-notification', async (req, res) => {
const { fromUser, title, body } = req.body;
if (!fromUser || !title || !body) {
return res.status(400).json({ error: 'fromUser, title, and body are required' });
}
if (!admin.apps.length) {
return res.status(500).json({ error: 'Firebase Admin not initialized' });
}
try {
let totalRecipients = 0;
const promises = [];
// Send to all users except the sender
for (const [username, tokens] of userTokens.entries()) {
if (username === fromUser) continue; // Skip sender
for (const token of tokens) {
const message = {
token: token,
notification: {
title: title,
body: body
},
webpush: {
headers: {
'Urgency': 'high'
},
notification: {
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test'
},
fcm_options: {
link: '/'
}
},
android: {
priority: 'high',
notification: {
sound: 'default',
click_action: '/'
}
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1
}
}
}
};
promises.push(
admin.messaging().send(message)
.then(() => {
console.log(`Notification sent to ${username} successfully`);
totalRecipients++;
})
.catch((error) => {
console.error(`Error sending notification to ${username}:`, error);
// Remove invalid token
if (error.code === 'messaging/registration-token-not-registered') {
tokens.delete(token);
}
})
);
}
}
await Promise.all(promises);
res.json({
success: true,
recipients: totalRecipients,
message: `Notification sent to ${totalRecipients} recipient(s)`
});
} catch (error) {
console.error('Error sending notifications:', error);
res.status(500).json({ error: 'Failed to send notifications' });
}
});
// Get all registered users (for debugging)
app.get('/users', (req, res) => {
const users = {};
console.log('Current userTokens map:', userTokens);
console.log('Number of registered users:', userTokens.size);
for (const [username, tokens] of userTokens.entries()) {
users[username] = {
tokenCount: tokens.size,
tokens: Array.from(tokens)
};
}
res.json(users);
});
// Debug endpoint to check server status
app.get('/debug', (req, res) => {
res.json({
firebaseAdminInitialized: admin.apps.length > 0,
registeredUsers: userTokens.size,
userTokens: Object.fromEntries(
Array.from(userTokens.entries()).map(([user, tokens]) => [user, {
count: tokens.size,
tokens: Array.from(tokens)
}])
),
timestamp: new Date().toISOString()
});
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`FCM Test PWA server running on port ${PORT}`);
console.log(`Open http://localhost:${PORT} in your browser`);
console.log(`Server listening on all interfaces (0.0.0.0:${PORT})`);
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-frontend", "name": "rosterchirp-frontend",
"version": "0.12.7", "version": "0.12.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -76,10 +76,21 @@ function showRosterChirpNotification(data) {
} }
// ── FCM background messages ─────────────────────────────────────────────────── // ── FCM background messages ───────────────────────────────────────────────────
// Server sends notification+data messages (mirrors the working fcm-app pattern).
// payload.notification carries title/body; payload.data carries url/groupId.
// Fallback to payload.data.title/body supports any older data-only messages still
// in-flight during a deployment transition.
if (messaging) { if (messaging) {
messaging.onBackgroundMessage((payload) => { messaging.onBackgroundMessage((payload) => {
console.log('[SW] onBackgroundMessage received, data:', JSON.stringify(payload.data)); console.log('[SW] onBackgroundMessage received:', JSON.stringify({ notification: payload.notification, data: payload.data }));
return showRosterChirpNotification(payload.data || {}); const n = payload.notification || {};
const d = payload.data || {};
return showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || '/',
groupId: d.groupId || '',
});
}); });
} else { } else {
console.warn('[SW] Firebase messaging not initialised — push notifications disabled'); console.warn('[SW] Firebase messaging not initialised — push notifications disabled');

View File

@@ -193,6 +193,126 @@ function RegistrationTab({ onFeaturesChanged }) {
); );
} }
// ── Push Debug Tab ────────────────────────────────────────────────────────────
function DebugRow({ label, value, ok, bad }) {
const color = ok ? 'var(--success)' : bad ? 'var(--error)' : 'var(--text-secondary)';
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: 'var(--text-secondary)' }}>{label}</span>
<span style={{ color, fontFamily: 'monospace', fontSize: 12 }}>{value}</span>
</div>
);
}
function PushDebugTab() {
const toast = useToast();
const [debugData, setDebugData] = useState(null);
const [loading, setLoading] = useState(true);
const [testing, setTesting] = useState(false);
const permission = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
const cachedToken = localStorage.getItem('rc_fcm_token');
const load = async () => {
setLoading(true);
try {
const data = await api.pushDebug();
setDebugData(data);
} catch (e) {
toast(e.message || 'Failed to load debug data', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const doTest = async (mode) => {
setTesting(true);
try {
const result = await api.testPush(mode);
const sent = result.results?.find(r => r.status === 'sent');
const failed = result.results?.find(r => r.status === 'failed');
if (sent) toast(`Test sent (mode=${mode}) — check device for notification`, 'success');
else if (failed) toast(`Test failed: ${failed.error}`, 'error');
else toast('No subscription found — grant permission and reload', 'error');
} catch (e) {
toast(e.message || 'Test failed', 'error');
} finally {
setTesting(false);
}
};
const clearToken = () => {
localStorage.removeItem('rc_fcm_token');
toast('Cached token cleared — reload to re-register with server', 'info');
};
const box = { background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 14px', marginBottom: 14 };
const sectionLabel = { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 8 };
return (
<div>
<div className="settings-section-label" style={{ marginBottom: 14 }}>Push Notification Debug</div>
{/* This device */}
<div style={box}>
<div style={sectionLabel}>This Device</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
</div>
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button>
</div>
{/* Test push */}
<div style={box}>
<div style={sectionLabel}>Send Test Notification to This Device</div>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
<strong>notification</strong> same path as real messages (SW <code>onBackgroundMessage</code>)<br/>
<strong>browser</strong> Chrome shows it directly, bypasses the SW (confirm delivery works)
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm btn-primary" onClick={() => doTest('notification')} disabled={testing}>
{testing ? 'Sending…' : 'Test (notification)'}
</button>
<button className="btn btn-sm btn-secondary" onClick={() => doTest('browser')} disabled={testing}>
{testing ? 'Sending…' : 'Test (browser)'}
</button>
</div>
</div>
{/* Registered devices */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
</div>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Loading</p>
) : !debugData?.subscriptions?.length ? (
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>No FCM tokens registered.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{debugData.subscriptions.map(sub => (
<div key={sub.id} style={box}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{sub.name || sub.email}</span>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', background: 'var(--surface)', padding: '2px 7px', borderRadius: 4, border: '1px solid var(--border)' }}>{sub.device}</span>
</div>
<code style={{ fontSize: 10, color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.6, display: 'block' }}>
{sub.fcm_token}
</code>
</div>
))}
</div>
)}
</div>
);
}
// ── Web Push Tab ────────────────────────────────────────────────────────────── // ── Web Push Tab ──────────────────────────────────────────────────────────────
function WebPushTab() { function WebPushTab() {
const toast = useToast(); const toast = useToast();
@@ -287,6 +407,7 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
isTeam && { id: 'team', label: 'Team Management' }, isTeam && { id: 'team', label: 'Team Management' },
{ id: 'registration', label: 'Registration' }, { id: 'registration', label: 'Registration' },
{ id: 'webpush', label: 'Web Push' }, { id: 'webpush', label: 'Web Push' },
{ id: 'pushdebug', label: 'Push Debug' },
].filter(Boolean); ].filter(Boolean);
return ( return (
@@ -311,6 +432,7 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
{tab === 'team' && <TeamManagementTab />} {tab === 'team' && <TeamManagementTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />} {tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
{tab === 'webpush' && <WebPushTab />} {tab === 'webpush' && <WebPushTab />}
{tab === 'pushdebug' && <PushDebugTab />}
</div> </div>
</div> </div>
); );

View File

@@ -170,7 +170,8 @@ export const api = {
getFirebaseConfig: () => req('GET', '/push/firebase-config'), getFirebaseConfig: () => req('GET', '/push/firebase-config'),
subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }), subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
unsubscribePush: () => req('POST', '/push/unsubscribe'), unsubscribePush: () => req('POST', '/push/unsubscribe'),
testPush: (mode = 'data') => req('POST', `/push/test?mode=${mode}`), testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`),
pushDebug: () => req('GET', '/push/debug'),
// Link preview // Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`), getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),