diff --git a/bun.lockb b/bun.lockb index 2922ad3..f805921 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c83bb48..fd9e107 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "drizzle-orm": "^0.31.2", "gql.tada": "^1.7.5", "graphql-request": "^7.0.1", + "gtoken": "^7.1.0", "hono": "^4.3.6", "zod": "^3.23.8" }, diff --git a/src/controllers/token/index.spec.ts b/src/controllers/token/index.spec.ts index 3a0994b..2c79ce9 100644 --- a/src/controllers/token/index.spec.ts +++ b/src/controllers/token/index.spec.ts @@ -174,7 +174,6 @@ describe("requests the /token route", () => { }); it("token already exists in db, should not insert new entry", async () => { - const minimumTimestamp = DateTime.now(); await db .insert(deviceTokensTable) .values({ deviceId: "123", token: "123" }); diff --git a/src/libs/fcm/getGoogleAuthToken.ts b/src/libs/fcm/getGoogleAuthToken.ts new file mode 100644 index 0000000..bfff89c --- /dev/null +++ b/src/libs/fcm/getGoogleAuthToken.ts @@ -0,0 +1,26 @@ +import { GoogleToken } from "gtoken"; + +export async function getGoogleAuthToken(adminSdkJson: AdminSdkKey) { + const { privateKey, clientEmail } = adminSdkJson; + + const gToken = new GoogleToken({ + key: privateKey, + email: clientEmail, + scope: ["https://www.googleapis.com/auth/firebase.messaging"], + }); + return gToken.getToken().then((token) => token.access_token); +} + +interface AdminSdkKey { + type: string; + projectID: string; + privateKeyID: string; + privateKey: string; + clientEmail: string; + clientID: string; + authURI: string; + tokenURI: string; + authProviderX509CERTURL: string; + clientX509CERTURL: string; + universeDomain: string; +} diff --git a/src/libs/fcm/sendFcmMessage.ts b/src/libs/fcm/sendFcmMessage.ts new file mode 100644 index 0000000..f360eb8 --- /dev/null +++ b/src/libs/fcm/sendFcmMessage.ts @@ -0,0 +1,73 @@ +import { getGoogleAuthToken } from "./getGoogleAuthToken"; + +export async function sendFcmMessage( + adminSdkJson: string, + message: FcmMessagePayload, + isOnlyValidatingFcmMessage?: boolean, +) { + return fetch( + "https://fcm.googleapis.com/v1/projects/aniplay-73b59/messages:send", + { + method: "POST", + body: JSON.stringify({ + message, + validate_only: isOnlyValidatingFcmMessage, + }), + headers: { + Authorization: `Bearer ${await getGoogleAuthToken(JSON.parse(adminSdkJson))}`, + }, + }, + ).then((res) => res.json()); +} + +type SendFcmMessageResponse = + | { error: SendFcmMessageError } + | FcmMessagePayload; + +interface SendFcmMessageError { + code: number; + message: string; + status: string; + details: Detail[]; +} + +export interface Detail { + type: string; + errorCode: string; +} + +export type FcmMessagePayload = { + name?: string; + data?: Record; + notification?: Notification; + android?: Partial; + webpush?: {}; + apns?: {}; + fcm_options?: {}; +} & ( + | { token: string; topic?: never; condition?: never } + | { token?: never; topic: string; condition?: never } + | { token?: never; topic?: never; condition: string } +); + +interface Notification { + title: string; + body: string; + image: string; +} + +interface AndroidConfig { + collapse_key: string; + priority: "normal" | "high"; + /** + * How long (in seconds) the message should be kept in FCM storage if the device is offline. The maximum time to live supported is 4 weeks, and the default value is 4 weeks if not set. Set it to 0 if want to send the message immediately. In JSON format, the Duration type is encoded as a string rather than an object, where the string ends in the suffix "s" (indicating seconds) and is preceded by the number of seconds, with nanoseconds expressed as fractional seconds. For example, 3 seconds with 0 nanoseconds should be encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should be expressed in JSON format as "3.000000001s". The ttl will be rounded down to the nearest second. + * + * A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s". + */ + ttl: string; + restricted_package_name: string; + data: Record; + notification: {}; + fcm_options: {}; + direct_boot_ok: boolean; +} diff --git a/src/libs/fcm/verifyFcm.spec.ts b/src/libs/fcm/verifyFcm.spec.ts new file mode 100644 index 0000000..0808227 --- /dev/null +++ b/src/libs/fcm/verifyFcm.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; + +import { server } from "~/mocks"; +import "~/mocks/gToken"; + +import { verifyFcmToken } from "./verifyFcmToken"; + +server.listen(); + +describe("verifyFcmToken", () => { + it("valid token, returns true", async () => { + const token = + "7v8sy43aq0re4r8xe7rmr0cn1fsmh6phehnfla2pa73z899zmhyarivmkt4sj6pyv0py43u6p2sim6wz2vg9ypjp9rug1keoth7f6ll3gdvas4q020u3ah51r6bjgn51j6bd92ztmtof3ljpcm8q31njvndy65enm68"; + const res = await verifyFcmToken(token, '{"clientEmail": "test@test.com"}'); + + expect(res).toBeTrue(); + }); + + it("invalid token, returns false", async () => { + const token = "abc123"; + const res = await verifyFcmToken(token, '{"clientEmail": "test@test.com"}'); + + expect(res).toBeFalse(); + }); +}); diff --git a/src/libs/fcm/verifyFcmToken.ts b/src/libs/fcm/verifyFcmToken.ts new file mode 100644 index 0000000..95c5c93 --- /dev/null +++ b/src/libs/fcm/verifyFcmToken.ts @@ -0,0 +1,26 @@ +import { sendFcmMessage } from "./sendFcmMessage"; + +export async function verifyFcmToken( + token: string, + adminSdkJson: string, +): Promise { + return sendFcmMessage( + adminSdkJson, + { name: "token_verification", token }, + true, + ) + .then((response) => { + const error = "error" in response ? response.error : undefined; + if (error) { + console.error("Received error response while validating FCM token"); + console.error(JSON.stringify(error)); + } + + return !error; + }) + .catch((err) => { + console.error("Failed to verify FCM token", err); + + return false; + }); +} diff --git a/src/mocks/fcm.ts b/src/mocks/fcm.ts new file mode 100644 index 0000000..9f9ae76 --- /dev/null +++ b/src/mocks/fcm.ts @@ -0,0 +1,36 @@ +import { HttpResponse, http } from "msw"; + +import type { FcmMessagePayload } from "~/libs/fcm/sendFcmMessage"; + +export function mockFcmMessageResponse() { + return http.post<{}, { message: FcmMessagePayload; validate_only: boolean }>( + "https://fcm.googleapis.com/v1/projects/aniplay-73b59/messages:send", + async ({ request }) => { + const { message } = await request.json(); + const { name, token } = message; + + if (name === "token_verification") { + if (token?.length === 163) { + return HttpResponse.json({ name }); + } + + return HttpResponse.json({ + error: { + code: 400, + message: + "The registration token is not a valid FCM registration token", + status: "INVALID_ARGUMENT", + details: [ + { + "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", + errorCode: "INVALID_ARGUMENT", + }, + ], + }, + }); + } + + return HttpResponse.json(message); + }, + ); +} diff --git a/src/mocks/gToken.ts b/src/mocks/gToken.ts new file mode 100644 index 0000000..1183635 --- /dev/null +++ b/src/mocks/gToken.ts @@ -0,0 +1,30 @@ +import type { TokenOptions } from "gtoken"; + +import { mock } from "bun:test"; + +const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +class MockGoogleToken { + private email: string | undefined; + + constructor(options: TokenOptions) { + this.email = options.email; + } + + getToken() { + if (!this.email) { + return Promise.reject("No email provided"); + } + + if (!emailRegex.test(this.email)) { + return Promise.reject("Invalid email"); + } + + return Promise.resolve({ + access_token: "asd", + }); + } +} + +mock.module("gtoken", () => ({ GoogleToken: MockGoogleToken })); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 928ff95..864982b 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -8,6 +8,7 @@ import { getAnifyTitle } from "./anify/title"; import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistTitle } from "./anilist/title"; import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus"; +import { mockFcmMessageResponse } from "./fcm"; export const handlers = [ getAnilistSearchResults(), @@ -20,4 +21,5 @@ export const handlers = [ getAnifyEpisodes(), getAnifySources(), getAnifyTitle(), + mockFcmMessageResponse(), ];