v0.12.8 FCM bug fix
This commit is contained in:
15
fcm-app/.dockerignore
Normal file
15
fcm-app/.dockerignore
Normal 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
18
fcm-app/.env
Normal 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
15
fcm-app/.env.example
Normal 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
33
fcm-app/Dockerfile
Normal 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"]
|
||||
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal file
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal 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
209
fcm-app/README.md
Normal 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
|
||||
22
fcm-app/docker-compose.yml
Normal file
22
fcm-app/docker-compose.yml
Normal 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
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
57
fcm-app/nginx.conf
Normal 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
23
fcm-app/package.json
Normal 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
334
fcm-app/public/app.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
48
fcm-app/public/firebase-messaging-sw.js
Normal file
48
fcm-app/public/firebase-messaging-sw.js
Normal 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
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
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
111
fcm-app/public/index.html
Normal 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>
|
||||
28
fcm-app/public/manifest.json
Normal file
28
fcm-app/public/manifest.json
Normal 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
82
fcm-app/public/sw.js
Normal 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
244
fcm-app/server.js
Normal 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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user