feat: Android APP V1 - Experimental Alpha
This commit is contained in:
283
server/src/services/push-dispatch.service.ts
Normal file
283
server/src/services/push-dispatch.service.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import fs from 'fs';
|
||||
import http2 from 'http2';
|
||||
import path from 'path';
|
||||
import {
|
||||
createPrivateKey,
|
||||
createSign,
|
||||
sign
|
||||
} from 'crypto';
|
||||
import { getDataSource } from '../db';
|
||||
import { DeviceTokenEntity } from '../entities/DeviceTokenEntity';
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FcmServiceAccount {
|
||||
project_id: string;
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
}
|
||||
|
||||
let cachedFcmAccessToken: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
const resolved = path.resolve(filePath);
|
||||
const raw = fs.readFileSync(resolved, 'utf8');
|
||||
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function resolveFcmServiceAccount(): FcmServiceAccount | null {
|
||||
const inlineJson = process.env.FCM_SERVICE_ACCOUNT_JSON?.trim();
|
||||
|
||||
if (inlineJson) {
|
||||
return JSON.parse(inlineJson) as FcmServiceAccount;
|
||||
}
|
||||
|
||||
const filePath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim();
|
||||
|
||||
if (filePath) {
|
||||
return readJsonFile(filePath) as FcmServiceAccount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string | Buffer): string {
|
||||
return Buffer.from(value)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
async function getFcmAccessToken(serviceAccount: FcmServiceAccount): Promise<string> {
|
||||
if (cachedFcmAccessToken && cachedFcmAccessToken.expiresAt > Date.now() + 60_000) {
|
||||
return cachedFcmAccessToken.token;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const claimSet = base64UrlEncode(JSON.stringify({
|
||||
iss: serviceAccount.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
}));
|
||||
const unsigned = `${header}.${claimSet}`;
|
||||
const signer = createSign('RSA-SHA256');
|
||||
|
||||
signer.update(unsigned);
|
||||
signer.end();
|
||||
|
||||
const signature = base64UrlEncode(signer.sign(serviceAccount.private_key));
|
||||
const assertion = `${unsigned}.${signature}`;
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FCM OAuth token request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as { access_token: string; expires_in: number };
|
||||
|
||||
cachedFcmAccessToken = {
|
||||
token: payload.access_token,
|
||||
expiresAt: Date.now() + payload.expires_in * 1000
|
||||
};
|
||||
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function sendFcmMessage(token: string, notification: PushNotificationPayload): Promise<void> {
|
||||
const serviceAccount = resolveFcmServiceAccount();
|
||||
|
||||
if (!serviceAccount) {
|
||||
console.warn('[push] FCM credentials are not configured; skipping Android dispatch');
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = await getFcmAccessToken(serviceAccount);
|
||||
const response = await fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body
|
||||
},
|
||||
data: notification.data ?? {}
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
|
||||
throw new Error(`FCM dispatch failed (${response.status}): ${errorBody}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApnsConfig(): {
|
||||
key: string;
|
||||
keyId: string;
|
||||
teamId: string;
|
||||
bundleId: string;
|
||||
} | null {
|
||||
const keyPath = process.env.APNS_KEY_PATH?.trim();
|
||||
const keyId = process.env.APNS_KEY_ID?.trim();
|
||||
const teamId = process.env.APNS_TEAM_ID?.trim();
|
||||
const bundleId = process.env.APNS_BUNDLE_ID?.trim() || 'com.metoyou.app';
|
||||
|
||||
if (!keyPath || !keyId || !teamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: fs.readFileSync(path.resolve(keyPath), 'utf8'),
|
||||
keyId,
|
||||
teamId,
|
||||
bundleId
|
||||
};
|
||||
}
|
||||
|
||||
function buildApnsJwt(config: { key: string; keyId: string; teamId: string }): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: 'ES256', kid: config.keyId }));
|
||||
const claims = base64UrlEncode(JSON.stringify({ iss: config.teamId, iat: now }));
|
||||
const unsigned = `${header}.${claims}`;
|
||||
const privateKey = createPrivateKey(config.key);
|
||||
const signature = sign('sha256', Buffer.from(unsigned), {
|
||||
key: privateKey,
|
||||
dsaEncoding: 'ieee-p1363'
|
||||
});
|
||||
|
||||
return `${unsigned}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
async function sendApnsMessage(deviceToken: string, notification: PushNotificationPayload): Promise<void> {
|
||||
const config = resolveApnsConfig();
|
||||
|
||||
if (!config) {
|
||||
console.warn('[push] APNs credentials are not configured; skipping iOS dispatch');
|
||||
return;
|
||||
}
|
||||
|
||||
const host = process.env.APNS_USE_SANDBOX === 'true'
|
||||
? 'api.sandbox.push.apple.com'
|
||||
: 'api.push.apple.com';
|
||||
const jwt = buildApnsJwt(config);
|
||||
const payload = JSON.stringify({
|
||||
aps: {
|
||||
alert: {
|
||||
title: notification.title,
|
||||
body: notification.body
|
||||
},
|
||||
sound: 'default'
|
||||
},
|
||||
...notification.data
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = http2.connect(`https://${host}`);
|
||||
const request = client.request({
|
||||
':method': 'POST',
|
||||
':path': `/3/device/${deviceToken}`,
|
||||
authorization: `bearer ${jwt}`,
|
||||
'apns-topic': config.bundleId,
|
||||
'apns-push-type': 'alert',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
|
||||
request.setEncoding('utf8');
|
||||
request.on('response', (headers) => {
|
||||
const status = Number(headers[':status'] ?? 0);
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
request.on('end', () => {
|
||||
reject(new Error(`APNs dispatch failed (${status}): ${body}`));
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end(payload);
|
||||
client.on('error', reject);
|
||||
request.on('close', () => client.close());
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertDeviceToken(input: {
|
||||
userId: string;
|
||||
platform: 'ios' | 'android';
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const repository = getDataSource().getRepository(DeviceTokenEntity);
|
||||
const id = `${input.userId}:${input.platform}:${input.token}`;
|
||||
const record = repository.create({
|
||||
id,
|
||||
userId: input.userId,
|
||||
platform: input.platform,
|
||||
token: input.token,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
await repository.save(record);
|
||||
}
|
||||
|
||||
export async function listDeviceTokensForUser(userId: string): Promise<DeviceTokenEntity[]> {
|
||||
return getDataSource()
|
||||
.getRepository(DeviceTokenEntity)
|
||||
.find({ where: { userId } });
|
||||
}
|
||||
|
||||
export async function dispatchPushToUser(
|
||||
userId: string,
|
||||
notification: PushNotificationPayload
|
||||
): Promise<void> {
|
||||
const tokens = await listDeviceTokensForUser(userId);
|
||||
|
||||
await Promise.all(tokens.map(async (record) => {
|
||||
try {
|
||||
if (record.platform === 'android') {
|
||||
await sendFcmMessage(record.token, notification);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendApnsMessage(record.token, notification);
|
||||
} catch (error) {
|
||||
console.error('[push] Failed to dispatch to token', {
|
||||
userId,
|
||||
platform: record.platform,
|
||||
error
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user