297 lines
15 KiB
TypeScript
297 lines
15 KiB
TypeScript
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
|
import {
|
|
verifyPlayPurchase,
|
|
type PlayVerificationEnv,
|
|
type VerifyPurchaseRequest,
|
|
} from './play-verification';
|
|
|
|
/**
|
|
* MotorSignal — Cloudflare R2 Worker Gateway
|
|
* Secure authorization gateway for MotorSignal telemetry bundle storage & purchase verification.
|
|
*/
|
|
|
|
export interface Env extends PlayVerificationEnv {
|
|
R2_BUCKET: R2Bucket;
|
|
FIREBASE_PROJECT_ID: string;
|
|
ENVIRONMENT: string;
|
|
}
|
|
|
|
interface FirebaseTokenPayload extends JWTPayload {
|
|
user_id?: string;
|
|
email?: string;
|
|
firebase?: { sign_in_provider?: string };
|
|
}
|
|
|
|
const CORS_HEADERS = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
|
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256, X-MotorSignal-Size',
|
|
};
|
|
|
|
const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com');
|
|
const jwks = createRemoteJWKSet(FIREBASE_JWKS);
|
|
|
|
export default {
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
if (request.method === 'OPTIONS') return new Response(null, { headers: CORS_HEADERS });
|
|
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
const projectId = env.FIREBASE_PROJECT_ID || 'motoapp-d22e0';
|
|
|
|
try {
|
|
if (path === '/' || path === '/health') {
|
|
return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' });
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// Google Play Purchase Verification Endpoint
|
|
// Accessible by both SIGNED-IN and SIGNED-OUT users.
|
|
// -------------------------------------------------------------
|
|
if (request.method === 'POST' && path === '/billing/verify-play-purchase') {
|
|
// Enforce max request size (4KB) to mitigate payload abuse
|
|
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
|
|
if (contentLength > 4096) {
|
|
return jsonResponse({ error: 'Payload too large', code: 'MALFORMED_REQUEST' }, 413);
|
|
}
|
|
|
|
const body = (await request.json().catch(() => null)) as VerifyPurchaseRequest | null;
|
|
if (!body || typeof body !== 'object') {
|
|
return jsonResponse({ error: 'Invalid JSON payload', code: 'MALFORMED_REQUEST' }, 400);
|
|
}
|
|
|
|
// Optional Firebase authentication check for account convenience metadata (non-blocking)
|
|
const authHeader = request.headers.get('Authorization');
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const optToken = authHeader.substring(7).trim();
|
|
await verifyFirebaseToken(optToken, projectId).catch(() => null);
|
|
}
|
|
|
|
const result = await verifyPlayPurchase(body, env);
|
|
return jsonResponse(result.body, result.status);
|
|
}
|
|
|
|
const authHeader = request.headers.get('Authorization');
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
|
|
|
|
const idToken = authHeader.substring(7).trim();
|
|
const authUser = await verifyFirebaseToken(idToken, projectId);
|
|
if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
|
|
const authenticatedUid = authUser.sub;
|
|
|
|
if (request.method === 'DELETE' && path === '/account/cloud-data') {
|
|
if (authUser.firebase?.sign_in_provider === 'anonymous') {
|
|
return jsonResponse({ error: 'Permanent account required' }, 403);
|
|
}
|
|
const prefix = `users/${authenticatedUid}/`;
|
|
let cursor: string | undefined;
|
|
let deleted = 0;
|
|
do {
|
|
const listed = await env.R2_BUCKET.list({ prefix, cursor });
|
|
if (listed.objects.length > 0) {
|
|
await env.R2_BUCKET.delete(listed.objects.map((object) => object.key));
|
|
deleted += listed.objects.length;
|
|
}
|
|
cursor = listed.truncated ? listed.cursor : undefined;
|
|
} while (cursor);
|
|
return jsonResponse({ success: true, deletedObjects: deleted });
|
|
}
|
|
|
|
if (request.method === 'PUT' && path === '/r2/upload') {
|
|
const remoteRideId = request.headers.get('X-Remote-Ride-Id');
|
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
|
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
|
const clientSha256 = request.headers.get('X-Bundle-Sha256');
|
|
const appVersion = request.headers.get('X-App-Version') || 'unknown';
|
|
if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400);
|
|
const payloadBuffer = await request.arrayBuffer();
|
|
if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400);
|
|
const computedSha256 = bufferToHex(await crypto.subtle.digest('SHA-256', payloadBuffer));
|
|
if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) {
|
|
return jsonResponse({ error: 'Payload integrity check failed: SHA-256 mismatch', expected: clientSha256, computed: computedSha256 }, 422);
|
|
}
|
|
const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, {
|
|
httpMetadata: { contentType: 'application/zip' },
|
|
customMetadata: { uid: authenticatedUid, remoteRideId, sha256: computedSha256, appVersion, uploadedAt: new Date().toISOString() },
|
|
});
|
|
return jsonResponse({ success: true, objectKey, size: r2Object.size, etag: r2Object.etag, sha256: computedSha256, uploadedAt: new Date().toISOString() });
|
|
}
|
|
|
|
if (request.method === 'POST' && path === '/r2/upload-url') {
|
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
const remoteRideId = body.remoteRideId as string | undefined;
|
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400);
|
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
|
return jsonResponse({ uploadEndpoint: `${url.origin}/r2/upload`, objectKey, method: 'PUT', headers: { 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId } });
|
|
}
|
|
|
|
if (request.method === 'GET' && path === '/r2/download') {
|
|
const remoteRideId = url.searchParams.get('rideId');
|
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
|
|
|
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
|
|
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
|
|
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
|
|
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
|
|
|
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
|
const object = await env.R2_BUCKET.get(objectKey);
|
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
|
|
|
|
const headers = new Headers();
|
|
object.writeHttpMetadata(headers);
|
|
headers.set('etag', object.httpEtag);
|
|
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
|
const sha256 = object.customMetadata?.sha256;
|
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
|
headers.set('X-MotorSignal-Size', String(object.size));
|
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
|
return new Response(object.body, { status: 200, headers });
|
|
}
|
|
|
|
if (request.method === 'HEAD' && path === '/r2/download') {
|
|
const remoteRideId = url.searchParams.get('rideId');
|
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
|
|
|
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
|
|
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error' }, 503);
|
|
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
|
|
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
|
|
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
|
|
|
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
|
const object = await env.R2_BUCKET.head(objectKey);
|
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
|
|
|
|
const headers = new Headers();
|
|
object.writeHttpMetadata(headers);
|
|
headers.set('etag', object.httpEtag);
|
|
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
|
const sha256 = object.customMetadata?.sha256;
|
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
|
headers.set('X-MotorSignal-Size', String(object.size));
|
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
|
return new Response(null, { status: 200, headers });
|
|
}
|
|
|
|
if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') {
|
|
const remoteRideId = url.searchParams.get('rideId');
|
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
|
|
|
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
|
|
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error', code: 'TEMPORARY_ERROR' }, 503);
|
|
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403);
|
|
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404);
|
|
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409);
|
|
|
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
|
const object = await env.R2_BUCKET.head(objectKey);
|
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404);
|
|
|
|
const sha256 = object.customMetadata?.sha256;
|
|
const appVersion = object.customMetadata?.appVersion;
|
|
const uploadedAt = object.customMetadata?.uploadedAt;
|
|
|
|
const headers = new Headers();
|
|
headers.set('etag', object.httpEtag);
|
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
|
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
|
headers.set('X-MotorSignal-Size', String(object.size));
|
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
|
|
|
if (request.method === 'HEAD') {
|
|
return new Response(null, { status: 200, headers });
|
|
}
|
|
|
|
headers.set('Content-Type', 'application/json');
|
|
return new Response(JSON.stringify({
|
|
verified: true,
|
|
remoteRideId,
|
|
objectKey,
|
|
size: object.size,
|
|
etag: object.httpEtag,
|
|
sha256,
|
|
appVersion,
|
|
uploadedAt,
|
|
}), { status: 200, headers });
|
|
}
|
|
|
|
return jsonResponse({ error: 'Endpoint not found' }, 404);
|
|
} catch (err: unknown) {
|
|
console.error('MotorSignal Worker exception', err);
|
|
return jsonResponse({ error: 'Internal Worker Exception' }, 500);
|
|
}
|
|
},
|
|
};
|
|
|
|
async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise<FirebaseTokenPayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, jwks, {
|
|
issuer: `https://securetoken.google.com/${expectedProjectId}`,
|
|
audience: expectedProjectId,
|
|
algorithms: ['RS256'],
|
|
clockTolerance: 60,
|
|
});
|
|
if (typeof payload.sub !== 'string' || payload.sub.trim().length === 0) return null;
|
|
if (typeof payload.exp !== 'number') return null;
|
|
return payload as FirebaseTokenPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface OwnedRide { objectKey?: string; }
|
|
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid' | 'temporary-error';
|
|
|
|
/** Reads the authenticated user's ride document through Firestore REST. */
|
|
async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> {
|
|
const documentPath = encodeFirestorePath(`users/${uid}/rides/${remoteRideId}`);
|
|
const endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents/${documentPath}`;
|
|
const response = await fetch(endpoint, { method: 'GET', headers: { Authorization: `Bearer ${idToken}`, Accept: 'application/json' } });
|
|
|
|
if (response.status === 401 || response.status === 403) return 'unauthorized';
|
|
if (response.status === 404) return 'not-found';
|
|
if (response.status === 429 || response.status >= 500) return 'temporary-error';
|
|
if (!response.ok) {
|
|
console.error('Firestore ownership lookup failed', response.status);
|
|
return 'unauthorized';
|
|
}
|
|
|
|
const document = (await response.json()) as FirestoreDocument;
|
|
const fields = document.fields ?? {};
|
|
const ownerId = firestoreString(fields.ownerId) ?? firestoreString(fields.uid);
|
|
const storedId = firestoreString(fields.id) ?? firestoreString(fields.remoteRideId);
|
|
const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey);
|
|
|
|
if (ownerId !== uid) return 'unauthorized';
|
|
if (storedId && storedId !== remoteRideId) return 'invalid';
|
|
if (!objectKey || objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid';
|
|
return { objectKey };
|
|
}
|
|
|
|
interface FirestoreValue { stringValue?: string; mapValue?: { fields?: Record<string, FirestoreValue> }; }
|
|
interface FirestoreDocument { fields?: Record<string, FirestoreValue>; }
|
|
|
|
function firestoreString(value: FirestoreValue | undefined): string | undefined {
|
|
return typeof value?.stringValue === 'string' ? value.stringValue : undefined;
|
|
}
|
|
|
|
function encodeFirestorePath(path: string): string {
|
|
return path.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
function bufferToHex(buffer: ArrayBuffer): string {
|
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function isValidRideId(rideId: string): boolean {
|
|
return /^[a-zA-Z0-9_-]{3,100}$/.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\');
|
|
}
|