Release 202506091000
This commit is contained in:
60
web/src/lib/client/browserNotifications.ts
Normal file
60
web/src/lib/client/browserNotifications.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { typedLocalStorage } from '../localstorage'
|
||||
|
||||
type SafeResult = { success: false; error: string } | { success: true; error?: undefined }
|
||||
|
||||
export function supportsBrowserNotifications() {
|
||||
return 'Notification' in window
|
||||
}
|
||||
|
||||
export function isBrowserNotificationsEnabled() {
|
||||
return (
|
||||
supportsBrowserNotifications() &&
|
||||
Notification.permission === 'granted' &&
|
||||
typedLocalStorage.browserNotificationsEnabled.get()
|
||||
)
|
||||
}
|
||||
|
||||
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||
try {
|
||||
if (!supportsBrowserNotifications()) {
|
||||
return { success: false, error: 'Browser notifications are not supported' }
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
return { success: false, error: 'Notification permission denied' }
|
||||
}
|
||||
|
||||
typedLocalStorage.browserNotificationsEnabled.set(true)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Browser notification setup failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Browser notification setup failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function disableBrowserNotifications(): SafeResult {
|
||||
try {
|
||||
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Browser notification disable failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Browser notification disable failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function showBrowserNotification(title: string, options?: NotificationOptions) {
|
||||
if (!isBrowserNotificationsEnabled()) {
|
||||
console.warn('Browser notifications are not enabled')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new Notification(title, options)
|
||||
} catch (error) {
|
||||
console.error('Failed to show browser notification:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
187
web/src/lib/client/clientPushNotifications.ts
Normal file
187
web/src/lib/client/clientPushNotifications.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { ActionInput } from '../astroActions'
|
||||
import type { actions } from 'astro:actions'
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export type SafeResult =
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
error?: undefined
|
||||
}
|
||||
|
||||
export async function subscribeToPushNotifications(vapidPublicKey: string): Promise<SafeResult> {
|
||||
try {
|
||||
if (!supportsPushNotifications()) {
|
||||
return { success: false, error: 'Push notifications are not supported in this browser' }
|
||||
}
|
||||
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
return { success: false, error: 'Notification permission denied' }
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Subscription failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPushNotifications(): Promise<SafeResult> {
|
||||
try {
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return { success: false, error: 'No push subscription found' }
|
||||
|
||||
const unsubscribed = await subscription.unsubscribe()
|
||||
if (!unsubscribed) return { success: false, error: 'Failed to unsubscribe from browser' }
|
||||
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Unsubscription failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsPushNotifications() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
try {
|
||||
if (window.__SW_REGISTRATION__) return window.__SW_REGISTRATION__
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? null
|
||||
} catch (error) {
|
||||
console.error('Error getting service worker registration:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentSubscription() {
|
||||
try {
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return null
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubscription[]) {
|
||||
const currentSubscription = await getCurrentSubscription()
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
}
|
||||
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') {
|
||||
console.error('Push subscriptions are not a string')
|
||||
return []
|
||||
}
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) {
|
||||
console.error('Push subscriptions are not an array')
|
||||
return []
|
||||
}
|
||||
|
||||
if (!subscriptions.every(isServerSubscription)) {
|
||||
console.error('Push subscriptions are not valid')
|
||||
return subscriptions.filter(isServerSubscription)
|
||||
}
|
||||
|
||||
return subscriptions
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const s = subscription as Record<string, unknown>
|
||||
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
|
||||
}
|
||||
25
web/src/lib/colors.ts
Normal file
25
web/src/lib/colors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type TailwindColor =
|
||||
| 'amber'
|
||||
| 'black'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'emerald'
|
||||
| 'fuchsia'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'lime'
|
||||
| 'neutral'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'rose'
|
||||
| 'sky'
|
||||
| 'slate'
|
||||
| 'stone'
|
||||
| 'teal'
|
||||
| 'violet'
|
||||
| 'white'
|
||||
| 'yellow'
|
||||
| 'zinc'
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from 'astro:content'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface JSONObject {
|
||||
|
||||
@@ -50,4 +50,8 @@ export const typedLocalStorage = makeTypedLocalStorage({
|
||||
pushNotificationsBannerDismissedAt: {
|
||||
schema: z.coerce.date(),
|
||||
},
|
||||
browserNotificationsEnabled: {
|
||||
schema: z.boolean(),
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
56
web/src/lib/notificationOptions.ts
Normal file
56
web/src/lib/notificationOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
|
||||
|
||||
export type CustomNotificationOptions = NotificationOptions & {
|
||||
actions?: { action: string; title: string; icon?: string }[]
|
||||
timestamp: number
|
||||
data: NotificationData
|
||||
}
|
||||
|
||||
export function makeNotificationOptions(
|
||||
payload: NotificationPayload | null,
|
||||
options: { removeActions?: boolean } = {}
|
||||
) {
|
||||
const defaultOptions: CustomNotificationOptions = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
badge: '/favicon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
actions: options.removeActions
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
defaultActionUrl: '/notifications',
|
||||
payload: null,
|
||||
},
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return defaultOptions
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
body: payload.body || undefined,
|
||||
actions: options.removeActions
|
||||
? undefined
|
||||
: payload.actions.map((action) => ({
|
||||
action: action.action,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
})),
|
||||
data: {
|
||||
...defaultOptions.data,
|
||||
payload,
|
||||
},
|
||||
} as CustomNotificationOptions
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
|
||||
import type { NotificationAction } from './webPush'
|
||||
import type { NotificationAction } from './serverEventsTypes'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export function makeNotificationTitle(
|
||||
|
||||
@@ -1,100 +1,21 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { Client } from 'pg'
|
||||
import { startListener, stopListener } from './postgresListeners'
|
||||
|
||||
import { zodParseJSON } from './json'
|
||||
import { sendNotification } from './sendNotifications'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { AstroIntegration, HookParameters } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
import type { AstroIntegration } from 'astro'
|
||||
|
||||
const INTEGRATION_NAME = 'postgres-listener'
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
options: HookParameters<'astro:server:start'>
|
||||
) {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const results = await sendNotification(notificationId, logger)
|
||||
|
||||
logger.info(
|
||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresListener(): AstroIntegration {
|
||||
return {
|
||||
name: 'postgres-listener',
|
||||
hooks: {
|
||||
'astro:server:start': async (options) => {
|
||||
'astro:server:start': (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
|
||||
if (!payload) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, options)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
void startListener(logger)
|
||||
},
|
||||
|
||||
'astro:server:done': async ({ logger: originalLogger }) => {
|
||||
const logger = originalLogger.fork(INTEGRATION_NAME)
|
||||
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
'astro:server:done': (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
void stopListener(logger)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
112
web/src/lib/postgresListeners.ts
Normal file
112
web/src/lib/postgresListeners.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { getRedisServerEvents, type RedisServerEvents } from './redis/redisServerEvents'
|
||||
import { sendNotification } from './sendNotifications'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
|
||||
export async function startListener(
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>
|
||||
) {
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
const redisServerEvents = await getRedisServerEvents()
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = parseJSON(msg.payload)
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
!('id' in payload) ||
|
||||
typeof payload.id !== 'number' ||
|
||||
payload.id <= 0 ||
|
||||
!Number.isFinite(payload.id) ||
|
||||
!Number.isInteger(payload.id)
|
||||
) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, logger, redisServerEvents)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
|
||||
redisServerEvents: RedisServerEvents
|
||||
) {
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const results = await sendNotification(notificationId, logger, redisServerEvents)
|
||||
|
||||
logger.info(
|
||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopListener(logger: AstroIntegrationLogger) {
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJSON<T = unknown, D extends T | undefined = undefined>(
|
||||
stringValue: string | null | undefined,
|
||||
defaultValue?: D
|
||||
): D | T {
|
||||
if (!stringValue) return defaultValue as D
|
||||
|
||||
try {
|
||||
return JSON.parse(stringValue) as T
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return defaultValue as D
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { deserializeActionResult } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { REDIS_ACTIONS_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
@@ -29,11 +28,13 @@ const dataSchema = z.object({
|
||||
})
|
||||
|
||||
class RedisActionsSessions extends RedisGenericManager {
|
||||
private readonly prefix = 'actions_session:'
|
||||
|
||||
async store(data: z.input<typeof dataSchema>) {
|
||||
const sessionId = randomUUID()
|
||||
|
||||
const parsedData = dataSchema.parse(data)
|
||||
await this.redisClient.set(`actions-session:${sessionId}`, JSON.stringify(parsedData), {
|
||||
await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
|
||||
@@ -43,7 +44,7 @@ class RedisActionsSessions extends RedisGenericManager {
|
||||
async get(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return null
|
||||
|
||||
const key = `actions-session:${sessionId}`
|
||||
const key = `${this.prefix}${sessionId}`
|
||||
|
||||
const rawData = await this.redisClient.get(key)
|
||||
if (!rawData) return null
|
||||
@@ -60,10 +61,10 @@ class RedisActionsSessions extends RedisGenericManager {
|
||||
async delete(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return
|
||||
|
||||
await this.redisClient.del(`actions-session:${sessionId}`)
|
||||
await this.redisClient.del(`${this.prefix}${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const redisActionsSessions = await RedisActionsSessions.createAndConnect({
|
||||
expirationTime: REDIS_ACTIONS_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 5, // 5 minutes in seconds
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { REDIS_URL } from 'astro:env/server'
|
||||
import { createClient } from 'redis'
|
||||
|
||||
import { getServerEnvVariable } from '../serverEnvVariables'
|
||||
|
||||
const REDIS_URL = getServerEnvVariable('REDIS_URL')
|
||||
|
||||
type RedisGenericManagerOptions = {
|
||||
expirationTime: number
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { z } from 'astro:content'
|
||||
import { REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
@@ -11,11 +10,13 @@ const dataSchema = z.object({
|
||||
})
|
||||
|
||||
class RedisImpersonationSessions extends RedisGenericManager {
|
||||
private readonly prefix = 'impersonation_session:'
|
||||
|
||||
async store(data: z.input<typeof dataSchema>) {
|
||||
const sessionId = randomUUID()
|
||||
|
||||
const parsedData = dataSchema.parse(data)
|
||||
await this.redisClient.set(`impersonation-session:${sessionId}`, JSON.stringify(parsedData), {
|
||||
await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
|
||||
@@ -25,7 +26,7 @@ class RedisImpersonationSessions extends RedisGenericManager {
|
||||
async get(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return null
|
||||
|
||||
const key = `impersonation-session:${sessionId}`
|
||||
const key = `${this.prefix}${sessionId}`
|
||||
|
||||
const rawData = await this.redisClient.get(key)
|
||||
if (!rawData) return null
|
||||
@@ -36,10 +37,10 @@ class RedisImpersonationSessions extends RedisGenericManager {
|
||||
async delete(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return
|
||||
|
||||
await this.redisClient.del(`impersonation-session:${sessionId}`)
|
||||
await this.redisClient.del(`${this.prefix}${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const redisImpersonationSessions = await RedisImpersonationSessions.createAndConnect({
|
||||
expirationTime: REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
private readonly prefix = 'pregenerated_user_secret_token:'
|
||||
|
||||
/**
|
||||
* Stores a pre-generated token with expiration
|
||||
* @param token The pre-generated token
|
||||
*/
|
||||
async storePreGeneratedToken(token: string): Promise<void> {
|
||||
await this.redisClient.set(`pregenerated-user-secret-token:${token}`, '1', {
|
||||
await this.redisClient.set(`${this.prefix}${token}`, '1', {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
* @returns true if token was valid and consumed, false otherwise
|
||||
*/
|
||||
async validateAndConsumePreGeneratedToken(token: string): Promise<boolean> {
|
||||
const key = `pregenerated-user-secret-token:${token}`
|
||||
const key = `${this.prefix}${token}`
|
||||
const exists = await this.redisClient.exists(key)
|
||||
if (exists) {
|
||||
await this.redisClient.del(key)
|
||||
@@ -30,5 +30,5 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
}
|
||||
|
||||
export const redisPreGeneratedSecretTokens = await RedisPreGeneratedSecretTokens.createAndConnect({
|
||||
expirationTime: REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 5, // 5 minutes in seconds
|
||||
})
|
||||
|
||||
72
web/src/lib/redis/redisServerEvents.ts
Normal file
72
web/src/lib/redis/redisServerEvents.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { allServerEventsData, type ServerEventsData, type ServerEventsEvent } from '../serverEventsTypes'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
export class RedisServerEvents extends RedisGenericManager {
|
||||
private readonly prefix = 'server_events:'
|
||||
|
||||
/**
|
||||
* Broadcast an event to a user's server events listener.
|
||||
*
|
||||
* @param eventName - The event name to broadcast.
|
||||
* @param userId - The user ID to broadcast to.
|
||||
* @param data - The event to broadcast.
|
||||
*/
|
||||
async send<T extends keyof ServerEventsData>(
|
||||
userId: number,
|
||||
eventName: T,
|
||||
data: ServerEventsData[T]
|
||||
): Promise<void> {
|
||||
const channel = `${this.prefix}${String(userId)}:${eventName}` as const
|
||||
await this.redisClient.publish(channel, JSON.stringify(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to server events for a user.
|
||||
*
|
||||
* @param eventName - The event name to subscribe to.
|
||||
* @param userId - The user ID to subscribe to.
|
||||
* @param callback - The callback to call when the event is received.
|
||||
* @returns A cleanup function to unsubscribe.
|
||||
*/
|
||||
async addListener<T extends keyof ServerEventsData | 'all'>(
|
||||
eventName: T,
|
||||
userId: number,
|
||||
callback: (event: T extends 'all' ? ServerEventsEvent : Extract<ServerEventsEvent, { type: T }>) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
const subscriber = this.redisClient.duplicate()
|
||||
await subscriber.connect()
|
||||
|
||||
const channel =
|
||||
eventName === 'all'
|
||||
? allServerEventsData.map((eventName) => `${this.prefix}${String(userId)}:${eventName}` as const)
|
||||
: (`${this.prefix}${String(userId)}:${eventName}` as const)
|
||||
|
||||
await subscriber.subscribe(channel, (message, channelKey) => {
|
||||
try {
|
||||
const data = JSON.parse(message) as ServerEventsData[T extends 'all' ? keyof ServerEventsData : T]
|
||||
const type = channelKey.split(':')[2] as T extends 'all' ? keyof ServerEventsData : T
|
||||
const event = { type, data } as T extends 'all'
|
||||
? ServerEventsEvent
|
||||
: Extract<ServerEventsEvent, { type: T }>
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse notification stream event:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await subscriber.unsubscribe(channel)
|
||||
subscriber.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let redisServerEvents: RedisServerEvents | null = null
|
||||
|
||||
export async function getRedisServerEvents() {
|
||||
redisServerEvents ??= await RedisServerEvents.createAndConnect({
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
return redisServerEvents
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
import { REDIS_USER_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
class RedisSessions extends RedisGenericManager {
|
||||
@@ -74,5 +72,5 @@ class RedisSessions extends RedisGenericManager {
|
||||
}
|
||||
|
||||
export const redisSessions = await RedisSessions.createAndConnect({
|
||||
expirationTime: REDIS_USER_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { type NotificationPayload, sendPushNotification } from './webPush'
|
||||
import { sendPushNotification } from './webPush'
|
||||
|
||||
import type { RedisServerEvents } from './redis/redisServerEvents'
|
||||
import type { NotificationPayload } from './serverEventsTypes'
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export async function sendNotification(
|
||||
notificationId: number,
|
||||
logger: AstroIntegrationLogger | Console = console
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
|
||||
redisServerEvents: RedisServerEvents
|
||||
) {
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
@@ -123,17 +126,14 @@ export async function sendNotification(
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.info(`No push subscriptions found for user ${notification.user.name}`)
|
||||
return { success: 0, failure: 0, total: 0 }
|
||||
}
|
||||
|
||||
const notificationPayload = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification),
|
||||
actions: makeNotificationActions(notification, SITE_URL),
|
||||
} satisfies NotificationPayload
|
||||
|
||||
await redisServerEvents.send(notification.userId, 'new-notification', notificationPayload)
|
||||
|
||||
const subscriptionResults = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
|
||||
60
web/src/lib/serverEventsTypes.ts
Normal file
60
web/src/lib/serverEventsTypes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Assert } from './assert'
|
||||
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
|
||||
|
||||
export type NotificationAction = {
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
|
||||
url: string | null
|
||||
iconName?: string
|
||||
}
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body: string | null
|
||||
actions: NotificationAction[]
|
||||
}
|
||||
|
||||
export type NotificationData = {
|
||||
defaultActionUrl: string
|
||||
payload: NotificationPayload | null
|
||||
}
|
||||
|
||||
export type ServerEventsData = {
|
||||
'new-notification': NotificationPayload
|
||||
'new-connection': {
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
export const allServerEventsData = [
|
||||
'new-notification',
|
||||
'new-connection',
|
||||
] as const satisfies (keyof ServerEventsData)[]
|
||||
|
||||
type _ExpectServerEventsDataToHaveAllValues = Assert<
|
||||
Equals<(typeof allServerEventsData)[number], keyof ServerEventsData>
|
||||
>
|
||||
|
||||
export type ServerEventsEvent = {
|
||||
[K in keyof ServerEventsData]: {
|
||||
type: K
|
||||
data: ServerEventsData[K]
|
||||
}
|
||||
}[keyof ServerEventsData]
|
||||
|
||||
export type SSEEventMap = {
|
||||
[K in keyof ServerEventsData as `sse-${K}`]: CustomEvent<ServerEventsData[K]>
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Document {
|
||||
addEventListener<K extends keyof SSEEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: SSEEventMap[K]) => void
|
||||
): void
|
||||
dispatchEvent<K extends keyof SSEEventMap>(ev: SSEEventMap[K]): void
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,17 @@ import webpush, { WebPushError } from 'web-push'
|
||||
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { NotificationPayload } from './serverEventsTypes'
|
||||
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
|
||||
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
|
||||
// Configure VAPID keys
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
|
||||
export { webpush }
|
||||
|
||||
export type NotificationAction = {
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
|
||||
url: string | null
|
||||
iconName?: string
|
||||
}
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body: string | null
|
||||
actions: NotificationAction[]
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: {
|
||||
endpoint: string
|
||||
|
||||
Reference in New Issue
Block a user