// venue.js // Copyright (C) 2022 Digital Telepresence, LLC // License: Apache-2.0 'use strict'; const mongoose = require('mongoose'); const VenueChannel = mongoose.model('VenueChannel'); const VenueChannelStatus = mongoose.model('VenueChannelStatus'); const https = require('https'); const fetch = require('node-fetch'); // jshint ignore:line const striptags = require('striptags'); const slug = require('slug'); const CACHE_DURATION = 60 * 5; const { SiteService, SiteError } = require('../../lib/site-lib'); class VenueService extends SiteService { constructor (dtp) { super(dtp, module.exports); this.soapboxDomain = process.env.DTP_SOAPBOX_HOST || 'shing.tv'; } async start ( ) { const { user: userService } = this.dtp.services; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; this.httpsAgent = new https.Agent({ rejectUnauthorized: false, }); this.populateVenueChannel = [ { path: 'owner', select: userService.USER_SELECT, }, ]; } channelMiddleware ( ) { return async (req, res, next) => { try { res.locals.venue = res.locals.venue || { }; res.locals.venue.channels = [ ]; await VenueChannel .find() .populate(this.populateVenueChannel) .lean() .cursor() .eachAsync(async (channel) => { channel.currentStatus = await this.updateChannelStatus(channel); res.locals.venue.channels.push(channel); }); return next(); } catch (error) { this.log.error('failed to populate Soapbox channel feed', { error }); return next(); } }; } async createChannel (owner, channelDefinition) { const channel = new VenueChannel(); channel.ownerType = owner.type; channel.owner = owner._id; channel.slug = this.getChannelSlug(channelDefinition.url); channel.sortOrder = parseInt(channelDefinition.sortOrder || '0', 10); if (!channelDefinition['credentials.streamKey'] || (channelDefinition['credentials.streamKey'] === '')) { throw new SiteError(400, 'Must provide a stream key'); } if (!channelDefinition['credentials.widgetKey'] || (channelDefinition['credentials.widgetKey'] === '')) { throw new SiteError(400, 'Must provide a widget key'); } channel.credentials = { streamKey: channelDefinition['credentials.streamKey'].trim(), widgetKey: channelDefinition['credentials.widgetKey'].trim(), }; const status = await this.updateChannelStatus(channel); channel.name = status.name; channel.description = status.description; await channel.save(); await this.updateChannelStatus(channel); return channel.toObject(); } async updateChannel (channel, owner, channelDefinition) { const updateOp = { $set: { } }; updateOp.$set.ownerType = owner.type; updateOp.$set.owner = owner._id; updateOp.$set.slug = this.getChannelSlug(channelDefinition.url); updateOp.$set.sortOrder = parseInt(channelDefinition.sortOrder || '0', 10); const status = await this.updateChannelStatus(channel); updateOp.$set.name = status.name; updateOp.$set.description = status.description; if (!channelDefinition['credentials.streamKey'] || (channelDefinition['credentials.streamKey'] === '')) { throw new SiteError(400, 'Must provide a stream key'); } updateOp.$set['credentials.streamKey'] = channelDefinition['credentials.streamKey'].trim(); if (!channelDefinition['credentials.widgetKey'] || (channelDefinition['credentials.widgetKey'] === '')) { throw new SiteError(400, 'Must provide a widget key'); } updateOp.$set['credentials.widgetKey'] = channelDefinition['credentials.widgetKey'].trim(); channel = await VenueChannel.findOneAndUpdate({ _id: channel._id }, updateOp, { new: true }); await this.updateChannelStatus(channel); } async getChannels (pagination, options) { options = Object.assign({ featuredOnly: false, withCredentials: false }, options); const search = { }; let q = VenueChannel .find(search) .sort({ sortOrder: 1, name: 1, slug: 1 }); if (pagination) { q = q.skip(pagination.skip).limit(pagination.cpp); } if (options.withCredentials) { q = q.select('+credentials'); } const channels = await q.populate(this.populateVenueChannel).lean(); for await (const channel of channels) { channel.currentStatus = await this.updateChannelStatus(channel); } return channels; } async getChannelById (channelId, options) { options = Object.assign({ withCredentials: false }, options); let q = VenueChannel.findOne({ _id: channelId }); if (options.withCredentials) { q = q.select('+credentials'); } const channel = await q.populate(this.populateVenueChannel).lean(); channel.currentStatus = await this.updateChannelStatus(channel); return channel; } async getChannelBySlug (channelSlug, options) { options = Object.assign({ withCredentials: false }, options); let q = VenueChannel.findOne({ slug: channelSlug.toLowerCase().trim() }); if (options.withCredentials) { q = q.select('+credentials'); } const channel = await q.populate(this.populateVenueChannel).lean(); channel.currentStatus = await this.updateChannelStatus(channel); return channel; } async getChannelFeed (channel, options) { const { cache: cacheService } = this.dtp.services; const cacheKey = `venue:ch:${channel.slug}`; options = Object.assign({ allowCache: true }, options); let json; if (options.allowCache) { json = await cacheService.getObject(cacheKey); if (json) { return json; } } const requestUrl = `https://${this.soapboxDomain}/channel/${channel.slug}/feed/json`; this.log.info('fetching Shing channel feed', { slug: channel.slug, requestUrl }); const response = await fetch(requestUrl, { agent: this.httpsAgent, }); if (!response.ok) { throw new SiteError(500, `Failed to fetch Shing channel feed: ${response.statusText}`); } json = await response.json(); await cacheService.setObjectEx(cacheKey, CACHE_DURATION, json); return json; } async updateChannelStatus (channel) { const requestUrl = `https://${this.soapboxDomain}/channel/${channel.slug}/status`; this.log.info('fetching Shing channel status', { slug: channel.slug, requestUrl }); const response = await fetch(requestUrl, { agent: this.httpsAgent }); if (!response.ok) { throw new SiteError(500, `Failed to fetch channel status: ${response.statusText}`); } const json = await response.json(); if (!json.success) { throw new Error(`failed to fetch channel status: ${json.message}`); } return json.channel; } getChannelSlug (channelUrl) { const { URL } = require('url'); const url = new URL(channelUrl); if (url.host !== this.soapboxDomain) { throw new SiteError(400, 'This is not a valid DTP stream channel URL: Domain mismatch.'); } const channelUrlParts = url.pathname.split('/').filter((part) => part.length > 0); if (channelUrlParts[0] !== 'channel') { throw new SiteError(400, 'This is not a valid DTP stream channel URL: Not on channel path.'); } return slug(striptags(channelUrlParts[1].trim())); } async removeChannel (channel) { await VenueChannelStatus.deleteMany({ channel: channel._id }); await VenueChannel.deleteOne({ _id: channel._id }); } } module.exports = { slug: 'venue', name: 'venue', className: 'VenueService', create: (dtp) => { return new VenueService(dtp); }, };