Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

View 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"
}
}

View 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
}

View 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 })
}

View 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 })
})