Initial commit
This commit is contained in:
6
pocketbase/sidecar/deno.json
Normal file
6
pocketbase/sidecar/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --watch --allow-net --allow-env main.ts",
|
||||
"start": "deno run --allow-net --allow-env main.ts"
|
||||
}
|
||||
}
|
||||
104
pocketbase/sidecar/fcmAuth.ts
Normal file
104
pocketbase/sidecar/fcmAuth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
interface ServiceAccount {
|
||||
client_email: string
|
||||
private_key: string
|
||||
project_id: string
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let serviceAccount: ServiceAccount | null = null
|
||||
let privateKey: CryptoKey | null = null
|
||||
let cachedToken: CachedToken | null = null
|
||||
|
||||
export function getServiceAccount(): ServiceAccount {
|
||||
if (serviceAccount) return serviceAccount
|
||||
|
||||
const credentialsJson = Deno.env.get('GOOGLE_CREDENTIALS_JSON')
|
||||
if (!credentialsJson?.trim()) {
|
||||
throw new Error('GOOGLE_CREDENTIALS_JSON environment variable is required')
|
||||
}
|
||||
|
||||
serviceAccount = JSON.parse(credentialsJson)
|
||||
return serviceAccount!
|
||||
}
|
||||
|
||||
async function getPrivateKey(): Promise<CryptoKey> {
|
||||
if (privateKey) return privateKey
|
||||
|
||||
const pem = getServiceAccount().private_key
|
||||
const pemContents = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '')
|
||||
|
||||
const der = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0))
|
||||
|
||||
privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
der.buffer,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
|
||||
return privateKey
|
||||
}
|
||||
|
||||
function b64url(data: ArrayBuffer | string): string {
|
||||
const str = typeof data === 'string'
|
||||
? btoa(data)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(data)))
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const now = Date.now()
|
||||
|
||||
if (cachedToken && cachedToken.expiresAt > now + 60_000) {
|
||||
return cachedToken.token
|
||||
}
|
||||
|
||||
const sa = getServiceAccount()
|
||||
const iat = Math.floor(now / 1000)
|
||||
const exp = iat + 3600
|
||||
|
||||
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
||||
const payload = b64url(JSON.stringify({
|
||||
iss: sa.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
exp,
|
||||
iat
|
||||
}))
|
||||
|
||||
const signingInput = `${header}.${payload}`
|
||||
const key = await getPrivateKey()
|
||||
const signature = await crypto.subtle.sign(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
)
|
||||
|
||||
const jwt = `${signingInput}.${b64url(signature)}`
|
||||
|
||||
const res = 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: jwt
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get FCM access token: ${await res.text()}`)
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json()
|
||||
cachedToken = { token: access_token, expiresAt: now + expires_in * 1000 }
|
||||
|
||||
return access_token
|
||||
}
|
||||
74
pocketbase/sidecar/fcmPush.ts
Normal file
74
pocketbase/sidecar/fcmPush.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getAccessToken, getServiceAccount } from './fcmAuth.ts'
|
||||
|
||||
export async function handleFcmPush(req: Request): Promise<Response> {
|
||||
const secret = Deno.env.get('SIDECAR_SECRET')
|
||||
if (secret && req.headers.get('x-sidecar-secret') !== secret) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
let body: {
|
||||
tokens: string[]
|
||||
title: string
|
||||
body: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return new Response('Invalid JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const { tokens, title, body: messageBody, data } = body
|
||||
|
||||
if (!tokens?.length) {
|
||||
return new Response('No tokens provided', { status: 400 })
|
||||
}
|
||||
|
||||
const { project_id } = getServiceAccount()
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
tokens.map(token =>
|
||||
fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${project_id}/messages:send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: { title, body: messageBody },
|
||||
...(data ? { data } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const err = await r.json()
|
||||
throw new Error(err.error?.message ?? r.statusText)
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
console.error(`FCM failed for token ${tokens[i]}:`, result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`FCM: ${successCount} sent, ${failureCount} failed`)
|
||||
|
||||
return Response.json({ successCount, failureCount })
|
||||
}
|
||||
13
pocketbase/sidecar/main.ts
Normal file
13
pocketbase/sidecar/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { handleFcmPush } from './fcmPush.ts'
|
||||
|
||||
const port = Number(Deno.env.get('SIDECAR_PORT') ?? '8091')
|
||||
|
||||
Deno.serve({ port }, (req: Request) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/notify') {
|
||||
return handleFcmPush(req)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
})
|
||||
Reference in New Issue
Block a user