[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(push): use FCM for iOS & android

+56 -186
-1
api/so/sprk/actor/getProfile.ts
··· 16 16 server.so.sprk.actor.getProfile({ 17 17 auth: ctx.authVerifier.optionalStandardOrRole, 18 18 handler: async ({ auth, params, req }) => { 19 - 20 19 const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); 21 20 const labelers = ctx.reqLabelers(req); 22 21 const hydrateCtx = await ctx.hydrator.createContext({
+3 -27
config.ts
··· 34 34 35 35 labelsFromIssuerDids: string[]; 36 36 37 - // Push notifications 37 + // Push notifications (FCM handles both iOS and Android) 38 38 pushEnabled: boolean; 39 39 fcmServiceAccount?: string; 40 - apnsKeyId?: string; 41 - apnsTeamId?: string; 42 - apnsKeyPath?: string; 43 - apnsTopic?: string; 44 40 } 45 41 46 42 export class ServerConfig { ··· 84 80 85 81 const labelsFromIssuerDids = envList("SPRK_LABELS_FROM_ISSUER_DIDS") ?? []; 86 82 87 - // Push notifications 83 + // Push notifications (FCM handles both iOS and Android) 88 84 const pushEnabled = Deno.env.get("SPRK_PUSH_ENABLED") === "true"; 89 85 const fcmServiceAccount = envStr("SPRK_FCM_SERVICE_ACCOUNT"); 90 - const apnsKeyId = envStr("SPRK_APNS_KEY_ID"); 91 - const apnsTeamId = envStr("SPRK_APNS_TEAM_ID"); 92 - const apnsKeyPath = envStr("SPRK_APNS_KEY_PATH"); 93 - const apnsTopic = envStr("SPRK_APNS_TOPIC"); 94 86 95 87 return new ServerConfig({ 96 88 version, ··· 120 112 labelsFromIssuerDids, 121 113 pushEnabled, 122 114 fcmServiceAccount, 123 - apnsKeyId, 124 - apnsTeamId, 125 - apnsKeyPath, 126 - apnsTopic, 127 115 }); 128 116 } 129 117 ··· 209 197 return this.cfg.labelsFromIssuerDids; 210 198 } 211 199 212 - // Push notifications 200 + // Push notifications (FCM handles both iOS and Android) 213 201 get pushEnabled() { 214 202 return this.cfg.pushEnabled; 215 203 } 216 204 get fcmServiceAccount() { 217 205 return this.cfg.fcmServiceAccount; 218 - } 219 - get apnsKeyId() { 220 - return this.cfg.apnsKeyId; 221 - } 222 - get apnsTeamId() { 223 - return this.cfg.apnsTeamId; 224 - } 225 - get apnsKeyPath() { 226 - return this.cfg.apnsKeyPath; 227 - } 228 - get apnsTopic() { 229 - return this.cfg.apnsTopic; 230 206 } 231 207 }
+1 -5
data-plane/subscription.ts
··· 30 30 this.logger = getLogger(["appview", "subscription"]); 31 31 this.background = new BackgroundQueue(db, this.logger); 32 32 33 - // Create push service 33 + // Create push service (FCM handles both iOS and Android) 34 34 const pushTokens = new PushTokens(db); 35 35 this.pushService = new PushService(pushTokens, db, { 36 36 enabled: cfg.pushEnabled, 37 37 fcmServiceAccount: cfg.fcmServiceAccount, 38 - apnsKeyId: cfg.apnsKeyId, 39 - apnsTeamId: cfg.apnsTeamId, 40 - apnsKeyPath: cfg.apnsKeyPath, 41 - apnsTopic: cfg.apnsTopic, 42 38 }); 43 39 44 40 this.indexingSvc = new IndexingService(
+52 -153
utils/push.ts
··· 14 14 export interface PushConfig { 15 15 enabled: boolean; 16 16 fcmServiceAccount?: string; // JSON string of Firebase service account 17 - apnsKeyId?: string; 18 - apnsTeamId?: string; 19 - apnsKeyPath?: string; 20 - apnsTopic?: string; // Bundle ID for iOS app 21 17 } 22 18 23 19 interface FcmServiceAccount { ··· 34 30 private fcmAccessToken: string | null = null; 35 31 private fcmTokenExpiry: number = 0; 36 32 private fcmServiceAccount: FcmServiceAccount | null = null; 37 - private apnsPrivateKey: CryptoKey | null = null; 38 33 39 34 constructor(pushTokens: PushTokens, db: Database, config: PushConfig) { 40 35 this.logger = getLogger(["appview", "push"]); ··· 69 64 70 65 for (const token of tokens) { 71 66 try { 72 - if (token.platform === "ios") { 73 - const success = await this.sendApns(token, payload); 74 - if (!success) { 75 - invalidTokens.push(token.token); 76 - } 77 - } else if (token.platform === "android") { 78 - const success = await this.sendFcm(token, payload); 79 - if (!success) { 80 - invalidTokens.push(token.token); 81 - } 67 + const success = await this.sendFcm(token, payload); 68 + if (!success) { 69 + invalidTokens.push(token.token); 82 70 } 83 71 } catch (err) { 84 72 this.logger.error("Failed to send push notification", { ··· 113 101 } 114 102 115 103 const notification = await this.buildNotificationContent(payload); 116 - const message = { 104 + 105 + // Build base message 106 + const message: FcmMessage = { 117 107 message: { 118 108 token: token.token, 119 109 notification: { ··· 127 117 ...(payload.reasonSubject && 128 118 { reasonSubject: payload.reasonSubject }), 129 119 }, 130 - android: { 131 - priority: "high" as const, 132 - }, 133 120 }, 134 121 }; 135 122 123 + // Add platform-specific options 124 + if (token.platform === "ios") { 125 + message.message.apns = { 126 + headers: { 127 + "apns-priority": "10", 128 + }, 129 + payload: { 130 + aps: { 131 + sound: "default", 132 + badge: 1, 133 + }, 134 + }, 135 + }; 136 + } else if (token.platform === "android") { 137 + message.message.android = { 138 + priority: "high", 139 + }; 140 + } 141 + 136 142 const projectId = this.fcmServiceAccount.project_id; 137 143 const url = 138 144 `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; ··· 172 178 } 173 179 } 174 180 175 - private async sendApns( 176 - token: PushToken, 177 - payload: PushPayload, 178 - ): Promise<boolean> { 179 - if ( 180 - !this.config.apnsKeyId || !this.config.apnsTeamId || 181 - !this.config.apnsKeyPath 182 - ) { 183 - this.logger.warn("APNs not fully configured"); 184 - return true; // Don't mark as invalid if not configured 185 - } 186 - 187 - const jwt = await this.getApnsJwt(); 188 - if (!jwt) { 189 - return true; // Don't mark as invalid if we can't get a JWT 190 - } 191 - 192 - const notification = await this.buildNotificationContent(payload); 193 - const apnsPayload = { 194 - aps: { 195 - alert: { 196 - title: notification.title, 197 - body: notification.body, 198 - }, 199 - sound: "default", 200 - badge: 1, 201 - }, 202 - reason: payload.reason, 203 - author: payload.author, 204 - recordUri: payload.recordUri, 205 - ...(payload.reasonSubject && { reasonSubject: payload.reasonSubject }), 206 - }; 207 - 208 - const topic = this.config.apnsTopic || token.appId; 209 - const url = `https://api.push.apple.com/3/device/${token.token}`; 210 - 211 - try { 212 - const response = await fetch(url, { 213 - method: "POST", 214 - headers: { 215 - "authorization": `bearer ${jwt}`, 216 - "apns-topic": topic, 217 - "apns-push-type": "alert", 218 - "apns-priority": "10", 219 - }, 220 - body: JSON.stringify(apnsPayload), 221 - }); 222 - 223 - if (!response.ok) { 224 - const status = response.status; 225 - // 400 = Bad device token, 410 = Token is no longer active 226 - if (status === 400 || status === 410) { 227 - return false; // Mark as invalid 228 - } 229 - this.logger.error("APNs request failed", { status }); 230 - } 231 - 232 - return true; 233 - } catch (err) { 234 - this.logger.error("APNs request error", { err }); 235 - return true; // Don't mark as invalid on network errors 236 - } 237 - } 238 - 239 181 private async buildNotificationContent( 240 182 payload: PushPayload, 241 183 ): Promise<{ title: string; body: string }> { ··· 399 341 } 400 342 } 401 343 402 - private async getApnsJwt(): Promise<string | null> { 403 - if ( 404 - !this.config.apnsKeyId || !this.config.apnsTeamId || 405 - !this.config.apnsKeyPath 406 - ) { 407 - return null; 408 - } 409 - 410 - try { 411 - // Load APNs private key if not already loaded 412 - if (!this.apnsPrivateKey) { 413 - const keyData = await Deno.readTextFile(this.config.apnsKeyPath); 414 - this.apnsPrivateKey = await this.importApnsKey(keyData); 415 - } 416 - 417 - const now = Math.floor(Date.now() / 1000); 418 - const header = { 419 - alg: "ES256", 420 - kid: this.config.apnsKeyId, 421 - }; 422 - 423 - const claim = { 424 - iss: this.config.apnsTeamId, 425 - iat: now, 426 - }; 427 - 428 - const encoder = new TextEncoder(); 429 - const headerB64 = this.base64UrlEncode( 430 - encoder.encode(JSON.stringify(header)), 431 - ); 432 - const claimB64 = this.base64UrlEncode( 433 - encoder.encode(JSON.stringify(claim)), 434 - ); 435 - const unsignedJwt = `${headerB64}.${claimB64}`; 436 - 437 - const signature = await crypto.subtle.sign( 438 - { name: "ECDSA", hash: "SHA-256" }, 439 - this.apnsPrivateKey, 440 - encoder.encode(unsignedJwt), 441 - ); 442 - 443 - // Convert DER signature to raw format for JWT 444 - const signatureB64 = this.base64UrlEncode(new Uint8Array(signature)); 445 - return `${unsignedJwt}.${signatureB64}`; 446 - } catch (err) { 447 - this.logger.error("Error creating APNs JWT", { err }); 448 - return null; 449 - } 450 - } 451 - 452 344 private async importPrivateKey(pem: string): Promise<CryptoKey> { 453 345 const pemContents = pem 454 346 .replace("-----BEGIN PRIVATE KEY-----", "") ··· 469 361 ); 470 362 } 471 363 472 - private async importApnsKey(pem: string): Promise<CryptoKey> { 473 - const pemContents = pem 474 - .replace("-----BEGIN PRIVATE KEY-----", "") 475 - .replace("-----END PRIVATE KEY-----", "") 476 - .replace(/\n/g, ""); 477 - 478 - const binaryDer = Uint8Array.from( 479 - atob(pemContents), 480 - (c) => c.charCodeAt(0), 481 - ); 482 - 483 - return await crypto.subtle.importKey( 484 - "pkcs8", 485 - binaryDer, 486 - { name: "ECDSA", namedCurve: "P-256" }, 487 - false, 488 - ["sign"], 489 - ); 490 - } 491 - 492 364 private base64UrlEncode(data: Uint8Array): string { 493 365 const base64 = btoa(String.fromCharCode(...data)); 494 366 return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 495 367 } 496 368 } 369 + 370 + // FCM message types 371 + interface FcmMessage { 372 + message: { 373 + token: string; 374 + notification: { 375 + title: string; 376 + body: string; 377 + }; 378 + data: Record<string, string>; 379 + android?: { 380 + priority: string; 381 + }; 382 + apns?: { 383 + headers: Record<string, string>; 384 + payload: { 385 + aps: { 386 + sound?: string; 387 + badge?: number; 388 + "interruption-level"?: string; 389 + "relevance-score"?: number; 390 + "mutable-content"?: number; 391 + }; 392 + }; 393 + }; 394 + }; 395 + }