/ support / ebsSupport / index.mjs
index.mjs
  1  ��import { sleep, serve } from "bun"

  2  import * as fs from "node:fs/promises"

  3  import * as crypto from "node:crypto"

  4  import * as path from "node:path"

  5  import * as events from  "node:events"

  6  import { chromium } from "playwright" 

  7  

  8  import * as util from "./util.mjs"

  9  import * as updates from "./updates.mjs"

 10  import * as token from "./token.mjs"

 11  

 12  import { TWITCH_IGNORED_CHANNELS, MakeTwitchClient } from "./twitch.mjs"

 13  import { ProductionDB, SupportDB } from "./model.mjs"

 14  import { MakeChatReader } from "./irc.mjs"

 15  import { MakeSampleCollector, summarizeSample } from "./sample.mjs"

 16  

 17  // Constants

 18  const DAY_IN_MS = 60e3*60*24

 19  const PUBLIC_BASE = "./public"

 20  

 21  // Server Helpers

 22  async function getLastModifiedTime(filePath) {

 23    const stats = await fs.stat(filePath)

 24    return stats.mtimeMs

 25  }

 26  

 27  function makeCORSHeaders(headers) {

 28    const CORS = {

 29      "Access-Control-Allow-Origin": "*",

 30      "Access-Control-Allow-Headers": "Content-Type",

 31      "Vary": "Origin"

 32    }

 33  

 34    return Object.assign(CORS, headers)

 35  }

 36  

 37  function initializeSession() {

 38    const  sessionId = crypto.randomBytes(32).toString('hex')

 39    ,     expiration = new Date(Date.now() + DAY_IN_MS)

 40    ,         cookie = `SUPPORTSESSIONID=${sessionId}; Expires=${expiration.toUTCString()}; HttpOnly`

 41    

 42    activeSessions.set(sessionId, expiration)

 43    setTimeout(() => { activeSessions.delete(sessionId) }, DAY_IN_MS)

 44  

 45    return cookie 

 46  } 

 47  

 48  function readCookies(pairs) {

 49    const parsed = {}

 50  

 51    for (const pair of pairs) {

 52      const [field, value] = pair.split("=")

 53      parsed[field] = value

 54    }

 55  

 56    return parsed

 57  }

 58  

 59  function hasSession(req) {

 60    if (process.env.NODE_ENV === "development") return true 

 61  

 62    const cookieHeader = req.headers.get("cookie")

 63  

 64    if (!cookieHeader) return false

 65  

 66    const rawCookies = cookieHeader.split("; ") 

 67    ,  parsedCookies = readCookies(rawCookies)

 68    ,      sessionId = parsedCookies["SUPPORTSESSIONID"]

 69    

 70    return activeSessions.has(sessionId)

 71  }

 72  

 73  // Server State

 74  const activeSessions = new Map()

 75  const activeSamples = new Map()

 76  

 77  const sampleConfig = {

 78    collectingSamples: false,

 79    sampleLength     : 60*60e3 ,

 80    maxActiveSamples : 50,

 81    maxViewers       : 250,

 82    seenChannels     : new Set(TWITCH_IGNORED_CHANNELS),

 83  }

 84  

 85  const sampleBrowser = await chromium.launch()

 86  const twitch = MakeTwitchClient()

 87  

 88  // Main Server

 89  async function serveEBSSupport() {

 90    const supportDB = SupportDB("support.sql")

 91    const productionDB = await ProductionDB()

 92  

 93    // Upgrade Support DB version

 94    supportDB.upgradeIfNeeded()

 95  

 96    // Add all Lead channels to ignore set

 97    for (const leadChannel of supportDB.getAllLeadChannels()) {

 98      sampleConfig.seenChannels.add(leadChannel)

 99      console.log(`added ${leadChannel} to ignored channels`)

100    }

101  

102    // Served Pages

103    const loginPage = await Bun.file("./login.html").bytes()

104    ,   supportPage = await Bun.file("./support.html").bytes()

105    

106    const handleIndex = async function handleIndexCallback(req) {

107      if (hasSession(req)) {

108        return new Response("", { status: 303, headers: { "Location": "/support" } }) 

109      } else {

110        return new Response("", { status: 303, headers: { "Location": "/login" } })

111      }

112    }

113  

114    const handleLogin = async function handleLoginCallback(req) {

115      if (hasSession(req)) {

116        return new Response("", { status: 303, headers: { "Location": "/support" } })

117      } else {

118        return new Response(loginPage, { headers: { "Content-Type": "text/html" } })

119      }

120    } 

121  

122    const handleSupport = async function handleSupportCallback(req) {

123      if (hasSession(req)) {

124        return new Response(supportPage, { headers: { "Content-Type": "text/html" } })

125      } else {

126        return new Response("", { status: 303, headers: { "Location": "/" } })

127      }

128    } 

129    

130    const handleAPIQuery = async function handleAPIQueryCallback(req, query) {

131      const lastUpdated = await getLastModifiedTime("./askebs.sql")

132  

133      if (!hasSession(req)) {

134        const errorMessage = Buffer.from(JSON.stringify({ error: "Unauthorized request" }))

135        return new Response(errorMessage, { status: 401, headers: { "Content-Type": "application/json" } })

136      }

137      

138      let final

139  

140      if (query) {

141        const data = productionDB.getAll(query)

142        final = Buffer.from(JSON.stringify({ lastUpdated, data }))

143      } else {

144        const sessionsData = productionDB.getAll("sessions")

145        ,    questionsData = productionDB.getAll("questions")

146        ,    purchasesData = productionDB.getAll("purchases")

147        ,      configsData = productionDB.getAll("configs")

148        

149        final = Buffer.from(JSON.stringify({

150          lastUpdated,

151          data: {

152             sessions: sessionsData,

153            questions: questionsData,

154            purchases: purchasesData,

155              configs: configsData,

156          }

157        }))

158      }

159  

160      const issued = token.SignJWT({ ts: Date.now() }, Bun.env.EBS_SUPPORT_SECRET)

161      return new Response(final, { headers: makeCORSHeaders({

162        "Content-Type": "application/json",

163        "Authorization": `Bearer ${issued}`,

164      })})

165    }

166  

167    const handleLatest = async function handleLatestCallback(req) {

168      if (!hasSession(req)) {

169        const errorMessage = Buffer.from(JSON.stringify({ error: "Unauthorized request" }))

170        return new Response(errorMessage, { status: 401, headers: { "Content-Type": "application/json" } })

171      }

172  

173      await productionDB.getLatest()

174      return await handleAPIQuery(req)

175    }

176  

177    const handleSupportQuery = async function handleSupportQueryCallback(req, query) {

178      if (!hasSession(req)) {

179        const errorMessage = Buffer.from(JSON.stringify({ error: "Unauthorized request" }))

180        return new Response(errorMessage, { status: 401, headers: { "Content-Type": "application/json" } })

181      }

182  

183      let final

184  

185      if (query === "leads") {

186        final = Buffer.from(JSON.stringify({ data: supportDB.getAllLeads() }))

187      }

188      

189      if (query === "samples") {

190        const samples = []

191        for (const sample of activeSamples.values()) { samples.push(summarizeSample(sample)) }

192        final = Buffer.from(JSON.stringify({ data: samples }))

193      }

194  

195      return new Response(final, { headers: makeCORSHeaders({ "Content-Type": "application/json" }) })

196    }

197    

198    const handleConfigureSample = async function handleConfigureSampleCallback(req, query) {

199      if (!hasSession(req)) {

200        const errorMessage = Buffer.from(JSON.stringify({ error: "Unauthorized request" }))

201        return new Response(errorMessage, { status: 401, headers: { "Content-Type": "application/json" } })

202      }

203  

204      const body = await Bun.readableStreamToFormData(req.body)

205      

206      sampleConfig.collectingSamples = body.get("collectingSamples") || sampleConfig.collectingSamples

207      sampleConfig.sampleLength = body.get("sampleLength") || sampleConfig.sampleLength

208      sampleConfig.maxActiveSamples = body.get("maxActiveSamples") || sampleConfig.maxActiveSamples

209      sampleConfig.maxViewers = body.get("maxViewers") || sampleConfig.maxViewers

210  

211      let final = JSON.stringify({ "status": "success" })

212      return new Response(final, { headers: makeCORSHeaders({ "Content-Type": "application/json" }) })

213    }

214  

215    const authenticateUser = async function authenticateUserCallback(req) {

216      const credentials = await Bun.readableStreamToFormData(req.body)

217      ,        hasCreds = supportDB.getUserPasswordHash(credentials.get("username"))

218  

219      if (hasCreds) {

220        const verified = await Bun.password.verify(credentials.get("password"), hasCreds.password) 

221        if (verified) {

222          await productionDB.getLatest()

223          return new Response("", { status: 303, headers: { "Set-Cookie": initializeSession(), "Location": "/support" } })

224        }

225      }

226      

227      return new Response("", { status: 303, headers: { "Location": "/login" } })

228    }

229  

230    const server = serve({

231      port: 1027,

232      tls: {

233        key: Bun.file("./privkey.pem"),

234        cert: Bun.file("./fullchain.pem")

235      },

236      async fetch (req, server) {

237        if (server.upgrade(req)) { return }

238  

239        const path = new URL(req.url).pathname

240  

241        // AUTH

242        if (req.method == "POST" && path == "/login") {

243          return await authenticateUser(req)

244        }

245  

246        // CLIENT

247        if (path === "/")        return await handleIndex(req)

248        if (path === "/login")   return await handleLogin(req)

249        if (path === "/support") return await handleSupport(req)

250  

251        // API

252        if (path === "/api/sessions")  return await handleAPIQuery(req,"sessions")

253        if (path === "/api/questions") return await handleAPIQuery(req,"questions")

254        if (path === "/api/configs")   return await handleAPIQuery(req,"configs")

255        if (path === "/api/purchases") return await handleAPIQuery(req,"purchases")

256        if (path === "/api/all")       return await handleAPIQuery(req)

257        if (path === "/api/latest")    return await handleLatest(req)

258  

259        if (path === "/api/samples")   return await handleSupportQuery(req, "samples")

260        if (path === "/api/leads")     return await handleSupportQuery(req, "leads")

261  

262        if (req.method == "POST" && path === "/api/samples/configure") {

263          return await handleConfigureSample(req)

264        }

265  

266        if (path === "/authorize" && Bun.env.NODE_ENV == "development") {

267          return new Response("", { status: 200, headers: { "Content-Type": "text/html" }})

268        }

269  

270        // Static Files

271        const staticFilePath = PUBLIC_BASE + new URL(req.url).pathname

272        ,     staticResource = await Bun.file(staticFilePath)

273  

274        if (staticResource) return new Response(staticResource)

275  

276        return new Response(null, { status: 404 })

277      },

278      error(error) {

279        console.error(error)

280        return new Response(`<pre>${error}\n${error.stack}</pre>`, { headers: { "Content-Type": "text/html" } })

281      },

282      websocket: {

283        message(ws, message) {

284          const { type, data } = JSON.parse(message)

285          switch (type) {

286            case "AUTH": {

287              const { issued } = data

288              if (token.VerifyJWT(issued, Bun.env.EBS_SUPPORT_SECRET)) {

289                ws.subscribe("updates")

290                ws.send(JSON.stringify({ type: "SUCCESS", data: "Subscribed to updates" }))

291              } else {

292                ws.send(JSON.stringify({ type: "FAILURE", data: "Invalid auth token" }))

293                ws.close(1000)

294              }

295              break

296            }

297            default: {}

298          }

299        },

300        open(ws) {

301          ws.send(JSON.stringify({ type: "AUTH", data: "Authenticate for Real-Time Updates" }))

302        },

303        close(ws, code, message) {},

304        drain(ws) {}, 

305      }

306    })

307  

308    // Chat reader module

309    const ChatReader = MakeChatReader()

310  

311    // Updates module

312    const realTimeUpdates = updates.MakeRealTimeUpdates(server)

313    realTimeUpdates.schedule("live", 5*60e3, async () => { return await twitch.GetLiveASKChannels() })

314  

315    // Samping for lead collection

316    const sampleCollector = await MakeSampleCollector(supportDB, realTimeUpdates, activeSamples, sampleConfig, ChatReader, sampleBrowser, twitch)

317    sampleCollector.beginSampling()

318  

319    // Initiate 24hr intervals for adding VOD links and refreshing SeenChannels for sample

320    async function updateSessionVODs() {

321      const sessionsData = productionDB.getAll("sessions")

322      const currentVODS = new Set(supportDB.getVODs().map(v => v.sessionId))

323      

324      const unCheckedSessions = sessionsData.filter(s => !currentVODS.has(s.id))

325  

326      for (const unchecked of unCheckedSessions) {

327        const video = await twitch.GetSessionVODs(unchecked.channel_id, unchecked.open)

328        supportDB.addVOD({

329          sessionId: unchecked.id,

330          userName: unchecked.twitch_userName,

331          vodLink: video ? `${video.url}?t=${util.makeDurationString(unchecked.open - video.start)}` : "NO VOD"

332        })

333      }

334    }

335    

336    async function refreshIgnoredChannels() {

337      const allLeads = supportDB.getAllLeadChannels()

338      sampleConfig.seenChannels = new Set(allLeads.concat(TWITCH_IGNORED_CHANNELS))

339    }

340    

341    updateSessionVODs()

342    refreshIgnoredChannels()

343  

344    setInterval(async () => {

345      updateSessionVODs()

346      refreshIgnoredChannels()

347    }, 24*60*60e3) 

348  

349    console.log(`${process.env.NODE_ENV.toUpperCase()}: Listening on ${server.url}`)

350  }

351  

352  // If we receive an interrupt, end process immediately

353  process.on("SIGTERM", () => { process.exit(0) })

354  process.on("SIGKILL", () => { process.exit(0) })

355  

356  serveEBSSupport()

357