OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / server.js

172 lines · javascript ref:
/**
 * HyperBookingServer — main server class.
 *
 * Follows hypernote pattern:
 * - Corestore + Hyperbee for data
 * - AuthManager for identity
 * - Router for HTTP dispatch
 * - Static file serving from web/
 */

import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import crypto from 'bare-crypto'

import { AuthManager } from './auth.js'
import { Router } from './router.js'
import { json, notFound, escapeHtml } from './response.js'
import { registerProviderRoutes } from './routes/providers.js'
import { registerPackageRoutes } from './routes/packages.js'
import { registerAvailabilityRoutes } from './routes/availability.js'
import { registerBookingRoutes } from './routes/bookings.js'
import { registerAuthRoutes } from './routes/auth.js'
import { registerWebRoutes, registerPublicRoutes } from './routes/web.js'
import { registerDashboardRoutes } from './routes/dashboard.js'
import { registerGcalRoutes } from './routes/gcal.js'
import { GoogleCalendarClient } from './gcal.js'

export class HyperBookingServer {
  constructor (options = {}) {
    this.port = options.port || 4050
    this.host = options.host || 'localhost'
    this.pathPrefix = options.pathPrefix || ''
    this.network = options.network || false
    this.appDir = path.dirname(new URL(import.meta.url).pathname)
    this.appDir = path.resolve(this.appDir, '..')

    // Storage
    this.storage = options.storage || path.join(os.homedir(), '.config', 'hyper-booking', 'data')
    this.store = new Corestore(this.storage)

    // Registry (Hyperbee for all structured data)
    this.registryCore = this.store.get({ name: 'registry' })
    this.db = new Hyperbee(this.registryCore, {
      keyEncoding: 'utf-8',
      valueEncoding: 'json'
    })

    // Auth
    this.authManager = new AuthManager({
      registry: this.db,
      trustedProxies: options.trustedProxies
    })

    // Google Calendar integration (optional — requires GCAL_CLIENT_ID env var)
    const gcalClientId = options.gcalClientId || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_ID)
    const gcalClientSecret = options.gcalClientSecret || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_SECRET)
    // Always create gcal client — routes check if credentials exist
    this.gcal = new GoogleCalendarClient({
      clientId: gcalClientId || '',
      clientSecret: gcalClientSecret || ''
    })
    this.gcalConfigured = !!gcalClientId

    // Fixed OAuth redirect URI (Google blocks .local and private IPs)
    const gcalRedirectUri = options.gcalRedirectUri || (typeof process !== 'undefined' && process.env && process.env.GCAL_REDIRECT_URI)
    this.gcalRedirectUri = gcalRedirectUri || null

    // Router
    this.router = new Router(this)

    // HTTP server
    this.httpServer = null
  }

  async ready () {
    await this.db.ready()
    console.log('[hyper-booking] Storage ready:', this.storage)

    // First-run bootstrap
    const initialized = await this.db.get('meta:initialized')
    if (!initialized) {
      await this.db.put('meta:initialized', {
        at: Date.now(),
        version: '0.1.0'
      })
      console.log('[hyper-booking] First run — admin account will be created on first sign-in')
    }
  }

  async start () {
    this.setupRoutes()

    this.httpServer = http.createServer(async (req, res) => {
      // CORS
      const origin = req.headers.origin || ''
      const allowed = !origin || this.isAllowedOrigin(origin)

      if (allowed && origin) {
        res.setHeader('Access-Control-Allow-Origin', origin)
        res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
        res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Auth-Token, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge')
        res.setHeader('Access-Control-Max-Age', '86400')
      }

      if (req.method === 'OPTIONS') {
        res.statusCode = 204
        res.end()
        return
      }

      try {
        const matched = await this.router.dispatch(req, res)
        if (!matched) notFound(res)
      } catch (err) {
        console.error('[Server] Error:', err)
        if (!res.writableEnded) {
          res.statusCode = 500
          res.setHeader('Content-Type', 'application/json')
          res.end(JSON.stringify({ error: 'Internal server error' }))
        }
      }
    })

    this.httpServer.setTimeout(120000)

    return new Promise((resolve) => {
      this.httpServer.listen(this.port, this.host, resolve)
    })
  }

  setupRoutes () {
    registerWebRoutes(this.router, this)
    registerAuthRoutes(this.router, this)
    registerProviderRoutes(this.router, this)
    registerPackageRoutes(this.router, this)
    registerAvailabilityRoutes(this.router, this)
    registerBookingRoutes(this.router, this)
    registerDashboardRoutes(this.router, this)
    registerGcalRoutes(this.router, this)
    // Public slug routes MUST be last (catch-all /:slug)
    registerPublicRoutes(this.router, this)
  }

  isAllowedOrigin (origin) {
    if (!origin) return true
    // Allow localhost, LAN IPs, and any same-host origin
    if (origin.startsWith('http://localhost')) return true
    if (origin.startsWith('http://127.0.0.1')) return true
    if (origin.startsWith('https://localhost')) return true
    if (origin.match(/^https?:\/\/192\.168\./)) return true
    if (origin.match(/^https?:\/\/10\./)) return true
    if (origin.match(/^https?:\/\/172\.(1[6-9]|2\d|3[01])\./)) return true
    if (origin === 'https://7rnx.net') return true
    return false
  }

  // ---- Helpers for route handlers ----

  generateId (prefix) {
    return prefix + '_' + crypto.randomBytes(12).toString('hex')
  }

  async close () {
    if (this.httpServer) this.httpServer.close()
    if (this.store) await this.store.close()
  }
}