feat: Android APP V1 - Experimental Alpha
This commit is contained in:
@@ -22,6 +22,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, `serverTag`, and `linkPreview`. When `serverTag` is empty, `GET /api/health` falls back to the server's public URL.
|
||||
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
|
||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||
- Mobile push dispatch uses optional credentials from the repository-root `.env` file (see `.env.example`): `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` for Android, and `APNS_KEY_PATH` / `APNS_KEY_ID` / `APNS_TEAM_ID` (+ optional `APNS_BUNDLE_ID`, `APNS_USE_SANDBOX`) for iOS. Device tokens are stored in the SQLite `device_tokens` table via `POST /api/users/device-tokens`.
|
||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
PluginUserMetadataEntity,
|
||||
DeviceTokenEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import {
|
||||
@@ -270,7 +271,8 @@ export async function initDatabase(): Promise<void> {
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
PluginUserMetadataEntity,
|
||||
DeviceTokenEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
25
server/src/entities/DeviceTokenEntity.ts
Normal file
25
server/src/entities/DeviceTokenEntity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('device_tokens')
|
||||
@Index('idx_device_tokens_user_id', ['userId'])
|
||||
export class DeviceTokenEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('text')
|
||||
platform!: 'ios' | 'android';
|
||||
|
||||
@Column('text')
|
||||
token!: string;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export type { ServerPluginEventDirection, ServerPluginEventScope } from './Serve
|
||||
export { PluginDataEntity } from './PluginDataEntity';
|
||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||
export { DeviceTokenEntity } from './DeviceTokenEntity';
|
||||
|
||||
23
server/src/migrations/1000000000010-DeviceTokens.ts
Normal file
23
server/src/migrations/1000000000010-DeviceTokens.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeviceTokens1000000000010 implements MigrationInterface {
|
||||
name = 'DeviceTokens1000000000010';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "device_tokens" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_device_tokens_user_id" ON "device_tokens" ("userId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "device_tokens"`);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
||||
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -19,5 +20,6 @@ export const serverMigrations = [
|
||||
GameMatchMisses1000000000006,
|
||||
PluginSupport1000000000007,
|
||||
ServerPluginInstallMetadata1000000000008,
|
||||
ServerIcons1000000000009
|
||||
ServerIcons1000000000009,
|
||||
DeviceTokens1000000000010
|
||||
];
|
||||
|
||||
58
server/src/routes/device-tokens.ts
Normal file
58
server/src/routes/device-tokens.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
dispatchPushToUser,
|
||||
listDeviceTokensForUser,
|
||||
upsertDeviceToken
|
||||
} from '../services/push-dispatch.service';
|
||||
|
||||
export interface DeviceTokenRecord {
|
||||
userId: string;
|
||||
platform: 'ios' | 'android';
|
||||
token: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
|
||||
|
||||
if (!userId || !token || (platform !== 'ios' && platform !== 'android')) {
|
||||
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
|
||||
}
|
||||
|
||||
await upsertDeviceToken({ userId, platform, token });
|
||||
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/:userId', async (req, res) => {
|
||||
const records = await listDeviceTokensForUser(req.params.userId);
|
||||
|
||||
res.json({
|
||||
tokens: records.map((record) => ({
|
||||
userId: record.userId,
|
||||
platform: record.platform,
|
||||
token: record.token,
|
||||
updatedAt: record.updatedAt
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:userId/dispatch', async (req, res) => {
|
||||
const { title, body, data } = req.body as {
|
||||
title?: string;
|
||||
body?: string;
|
||||
data?: Record<string, string>;
|
||||
};
|
||||
|
||||
if (!title || !body) {
|
||||
return res.status(400).json({ error: 'Missing title/body' });
|
||||
}
|
||||
|
||||
await dispatchPushToUser(req.params.userId, { title, body, data });
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import linkMetadataRouter from './link-metadata';
|
||||
import gamesRouter from './games';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import deviceTokensRouter from './device-tokens';
|
||||
import serversRouter from './servers';
|
||||
import pluginSupportRouter from './plugin-support';
|
||||
import openApiDocsRouter from './openapi-docs';
|
||||
@@ -18,6 +19,7 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/users/device-tokens', deviceTokensRouter);
|
||||
app.use('/api', openApiDocsRouter);
|
||||
app.use('/api/servers', pluginSupportRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
|
||||
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