mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(server): name the event in a push and offer a booking as a Google Wallet pass
The push payload carries the event it reports, so the Android app can file a notification under a channel of its own (chat, trip updates, invitations). A browser and the iOS extension ignore the field. GET /api/trips/:tripId/reservations/:id/wallet-pass/google sends the same request the Apple Wallet signer takes to the TREK services' Google route and answers with the signed save-to-wallet link the Android app opens. Off with the other TREK services, and a clear 404 while liketrek has no Wallet issuer yet.
This commit is contained in:
@@ -85,6 +85,7 @@ function payloadBytes(payload: PushPayload): number {
|
||||
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
|
||||
* one trip shows as one notification that keeps updating instead of a pile of
|
||||
* them. Everything else has no tag and never replaces an earlier notification.
|
||||
* The event goes along so the Android app can sort it into a channel.
|
||||
*
|
||||
* The body is cut, at a character boundary and with an ellipsis, until the
|
||||
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
|
||||
@@ -99,6 +100,8 @@ export function buildPushPayload(
|
||||
body: '',
|
||||
...(url ? { url } : {}),
|
||||
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
|
||||
// The Android app files it under a channel by event; a browser ignores it.
|
||||
...(msg.event ? { event: msg.event } : {}),
|
||||
};
|
||||
const full = { ...base, body: msg.body };
|
||||
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { User } from '../../types';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { WalletPassService } from './wallet-pass.service';
|
||||
import { walletLanguage } from './wallet-pass.words';
|
||||
import { Controller, Get, Param, ParseIntPipe, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { googleWalletSaveResponseSchema, type GoogleWalletSaveResponse } from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* GET /api/trips/:tripId/reservations/:id/wallet-pass: the booking's Apple
|
||||
* Wallet pass, which the iOS app hands to Wallet's own sheet (native/wallet.ts).
|
||||
* GET .../wallet-pass/google: the same booking as a Google Wallet save link,
|
||||
* which the Android app opens.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/reservations')
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach.
|
||||
@@ -32,4 +36,15 @@ export class WalletPassController {
|
||||
res.set('Content-Disposition', `attachment; filename="${pass.filename}"`);
|
||||
res.status(200).type('application/vnd.apple.pkpass').send(pass.bytes);
|
||||
}
|
||||
|
||||
@Get(':id/wallet-pass/google')
|
||||
@ResponseContract(googleWalletSaveResponseSchema)
|
||||
async google(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId', ParseIntPipe) tripId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('lang') lang: string | undefined,
|
||||
): Promise<GoogleWalletSaveResponse> {
|
||||
return { saveUrl: await this.passes.googleSaveUrlFor(user.id, tripId, id, walletLanguage(lang)) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import type { ConfigType } from '@nestjs/config';
|
||||
const PASSES_PER_MINUTE = 20;
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
/** The signer's answer for Google Wallet: a JSON object with one signed link. */
|
||||
const MAX_GOOGLE_ANSWER_BYTES = 16 * 1024;
|
||||
const GOOGLE_SAVE_PREFIX = 'https://pay.google.com/gp/v/save/';
|
||||
/** A pass is its texts plus TREK's icon and wordmark: some 25 KB. */
|
||||
const MAX_PASS_BYTES = 512 * 1024;
|
||||
const PKPASS = 'application/vnd.apple.pkpass';
|
||||
@@ -45,6 +48,27 @@ export class WalletPassService {
|
||||
}
|
||||
|
||||
async passFor(userId: number, tripId: number, reservationId: number, language: string): Promise<WalletPass> {
|
||||
const { request, title } = await this.requestFor(userId, tripId, reservationId, language);
|
||||
return { bytes: await this.sign(request), filename: `${filenameOf(title)}.pkpass` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking as a Google Wallet pass: the same request the Apple signer takes,
|
||||
* sent to the signer's Google route (�/v1/wallet/google beside �/v1/wallet/pass),
|
||||
* which answers with a save link signed for liketrek's Wallet issuer.
|
||||
*/
|
||||
async googleSaveUrlFor(userId: number, tripId: number, reservationId: number, language: string): Promise<string> {
|
||||
const { request } = await this.requestFor(userId, tripId, reservationId, language);
|
||||
return this.signGoogle(request);
|
||||
}
|
||||
|
||||
/** The signer's request for a booking the user can reach, after the switch and the rate limit. */
|
||||
private async requestFor(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
reservationId: number,
|
||||
language: string,
|
||||
): Promise<{ request: object; title: string }> {
|
||||
if (!this.enabled()) throw new DomainError(404, 'Wallet passes are off on this server');
|
||||
const allowed = await this.rateLimit.check(
|
||||
'wallet-pass',
|
||||
@@ -85,8 +109,51 @@ export class WalletPassService {
|
||||
await loadWalletWords(language),
|
||||
);
|
||||
if (!request) throw new DomainError(422, 'A booking needs a date for a Wallet pass');
|
||||
return { request, title: row.title };
|
||||
}
|
||||
|
||||
return { bytes: await this.sign(request), filename: `${filenameOf(row.title)}.pkpass` };
|
||||
private async signGoogle(request: object): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeFetchAdminConfigured(
|
||||
googleSignerUrl(this.config.walletPassUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
},
|
||||
0,
|
||||
undefined,
|
||||
MAX_GOOGLE_ANSWER_BYTES,
|
||||
);
|
||||
} catch (err) {
|
||||
logWarn(`[Wallet pass] Google signer unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw new DomainError(502, 'The Wallet pass service did not answer');
|
||||
}
|
||||
if (response.status === 429) throw new DomainError(429, 'Too many Wallet passes, try again in a minute');
|
||||
// The TREK services answer 404 while liketrek has no Google Wallet issuer yet.
|
||||
if (response.status === 404) throw new DomainError(404, 'Google Wallet passes are not available yet');
|
||||
const { bytes, truncated } = await readCapped(response, MAX_GOOGLE_ANSWER_BYTES).catch(() => ({
|
||||
bytes: Buffer.alloc(0),
|
||||
truncated: true,
|
||||
}));
|
||||
let saveUrl: unknown;
|
||||
try {
|
||||
saveUrl = (JSON.parse(bytes.toString('utf8')) as { saveUrl?: unknown }).saveUrl;
|
||||
} catch {
|
||||
saveUrl = undefined;
|
||||
}
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
truncated ||
|
||||
typeof saveUrl !== 'string' ||
|
||||
!saveUrl.startsWith(GOOGLE_SAVE_PREFIX)
|
||||
) {
|
||||
logWarn(`[Wallet pass] Google signer answered ${response.status}`);
|
||||
throw new DomainError(502, 'The Wallet pass service could not make this pass');
|
||||
}
|
||||
return saveUrl;
|
||||
}
|
||||
|
||||
private async sign(request: object): Promise<Buffer> {
|
||||
@@ -126,6 +193,11 @@ export class WalletPassService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The signer's Google route beside its Apple one: �/v1/wallet/pass becomes �/v1/wallet/google. */
|
||||
function googleSignerUrl(passUrl: string): string {
|
||||
return passUrl.replace(/\/pass\/?$/, '/google');
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user