OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / routes / gcal.js

212 lines · javascript ref:
/**
 * Google Calendar API routes — OAuth flow + calendar management.
 */

import crypto from 'bare-crypto'
import { json, notFound, redirect } from '../response.js'
import { requireAuth } from './auth-helpers.js'
import { readJson } from '../response.js'

export function registerGcalRoutes (router, server) {
  const gcal = server.gcal

  // Status — always available, returns configured + connected state
  router.get('/api/gcal/status', async (req, res) => {
    if (!server.gcalConfigured) {
      return json(res, { configured: false, connected: false })
    }

    // Try auth — if not signed in, just return configured status
    let auth
    try {
      const result = await server.authManager.authorizeRequest(req, 'write')
      if (!result.authorized || !result.auth) {
        return json(res, { configured: true, connected: false })
      }
      auth = result.auth
    } catch {
      return json(res, { configured: true, connected: false })
    }

    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { configured: true, connected: false })

    const entry = await server.db.get(`gcal:provider:${providerId}`)
    if (!entry || !entry.value.connected) {
      return json(res, { configured: true, connected: false })
    }

    json(res, {
      configured: true,
      connected: true,
      calendarIds: entry.value.calendarIds || ['primary'],
      connectedAt: entry.value.connectedAt
    })
  })

  // Get OAuth2 authorization URL
  router.get('/api/gcal/auth-url', async (req, res) => {
    if (!server.gcalConfigured) {
      return json(res, { error: 'Google Calendar not configured. Set GCAL_CLIENT_ID and GCAL_CLIENT_SECRET environment variables.' }, 400)
    }
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    // Generate CSRF nonce
    const nonce = crypto.randomBytes(16).toString('hex')
    const state = Buffer.from(JSON.stringify({ providerId, nonce })).toString('base64url')

    // Store nonce for validation (5-min TTL)
    await server.db.put(`gcal:state:${nonce}`, {
      providerId,
      createdAt: Date.now()
    })

    // OAuth redirect URI must be a fixed public URL (Google blocks .local, private IPs)
    const redirectUri = server.gcalRedirectUri
    const url = gcal.getAuthUrl(redirectUri, state)
    json(res, { url })
  })

  // OAuth2 callback — Google redirects here
  router.get('/api/gcal/callback', async (req, res) => {
    const url = new URL('http://x' + req.url)
    const code = url.searchParams.get('code')
    const stateParam = url.searchParams.get('state')
    const error = url.searchParams.get('error')

    if (error) {
      return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=${error}`)
    }

    if (!code || !stateParam) {
      return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=missing_params`)
    }

    // Decode and validate state
    let state
    try {
      state = JSON.parse(Buffer.from(stateParam, 'base64url').toString())
    } catch {
      return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=invalid_state`)
    }

    const { providerId, nonce } = state

    // Validate CSRF nonce
    const nonceEntry = await server.db.get(`gcal:state:${nonce}`)
    if (!nonceEntry) {
      return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
    }

    // Check nonce age (5 min max)
    if (Date.now() - nonceEntry.value.createdAt > 5 * 60 * 1000) {
      await server.db.del(`gcal:state:${nonce}`)
      return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
    }

    // Consume nonce
    await server.db.del(`gcal:state:${nonce}`)

    // Must match the same fixed redirect URI used in auth-url
    const redirectUri = server.gcalRedirectUri

    try {
      const tokens = await gcal.exchangeCode(code, redirectUri)

      // Store tokens
      await server.db.put(`gcal:provider:${providerId}`, {
        connected: true,
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
        tokenExpiry: Date.now() + (tokens.expiresIn * 1000),
        calendarIds: ['primary'],
        connectedAt: Date.now()
      })

      redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=connected`)
    } catch (err) {
      console.error('[GCal] Token exchange failed:', err.message)
      redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=token_exchange_failed`)
    }
  })

  // List available calendars from Google
  router.get('/api/gcal/calendars', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const accessToken = await gcal.getValidToken(server.db, providerId)
    if (!accessToken) return json(res, { error: 'Not connected to Google Calendar' }, 400)

    try {
      const calendars = await gcal.fetchCalendarList(accessToken)
      json(res, calendars)
    } catch (err) {
      json(res, { error: err.message }, 500)
    }
  })

  // Update selected calendars
  router.put('/api/gcal/calendars', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const body = await readJson(req)
    const { calendarIds } = body

    if (!Array.isArray(calendarIds) || calendarIds.length === 0) {
      return json(res, { error: 'calendarIds array required' }, 400)
    }

    const entry = await server.db.get(`gcal:provider:${providerId}`)
    if (!entry || !entry.value.connected) {
      return json(res, { error: 'Not connected' }, 400)
    }

    entry.value.calendarIds = calendarIds
    await server.db.put(`gcal:provider:${providerId}`, entry.value)

    // Clear cache (calendar selection changed)
    const delKeys = []
    for await (const e of server.db.createReadStream({
      gte: `gcal:cache:${providerId}:`,
      lte: `gcal:cache:${providerId}:~`
    })) {
      delKeys.push(e.key)
    }
    for (const key of delKeys) await server.db.del(key)

    json(res, { ok: true, calendarIds })
  })

  // Disconnect Google Calendar
  router.delete('/api/gcal/disconnect', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    await gcal.disconnect(server.db, providerId)
    json(res, { ok: true })
  })
}

async function getProviderIdForUser (server, userId) {
  const indexed = await server.db.get(`provider-by-user:${userId}`)
  if (indexed) return indexed.value

  for await (const entry of server.db.createReadStream({
    gte: 'provider:',
    lte: 'provider:~'
  })) {
    if (entry.value.userId === userId) {
      await server.db.put(`provider-by-user:${userId}`, entry.value.id)
      return entry.value.id
    }
  }
  return null
}