diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 0ce5984..78d74a6 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -69,9 +69,17 @@ class AdminController extends SiteController { } async getHomeView (req, res) { + const { + coreNode: coreNodeService, + dashboard: dashboardService, + } = this.dtp.services; + res.locals.stats = { + userSignupHourly: await dashboardService.getUserSignupsPerHour(), memberCount: await User.estimatedDocumentCount(), + constellation: await coreNodeService.getConstellationStats(), }; + res.render('admin/index'); } } diff --git a/app/controllers/admin/core-node.js b/app/controllers/admin/core-node.js index 3941536..56fbd46 100644 --- a/app/controllers/admin/core-node.js +++ b/app/controllers/admin/core-node.js @@ -1,10 +1,10 @@ -// admin/content-report.js +// admin/core-node.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; -const DTP_COMPONENT_NAME = 'admin:content-report'; +const DTP_COMPONENT_NAME = 'admin:core-node'; const express = require('express'); // const multer = require('multer'); @@ -36,13 +36,33 @@ class CoreNodeController extends SiteController { } async postCoreNodeConnect (req, res, next) { - // const { coreNode: coreNodeService } = this.dtp.services; + const { coreNode: coreNodeService } = this.dtp.services; + try { - + res.locals.core = await coreNodeService.resolveCore(req.body.host); + this.log.info('sending Core connection request', { core: res.locals.core }); + } catch (error) { + this.log.error('failed to resolve Core', { host: req.body.host, error }); + return next(error); + } + + try { + res.locals.txConnect = await coreNodeService.sendRequest(res.locals.core, { + method: 'POST', + url: '/core/connect/node', + tokenized: true, + body: { + version: this.dtp.pkg.version, + site: this.dtp.config.site, + }, + }); + this.log.info('connect tranaction', { txConnect: res.locals.txConnect }); } catch (error) { this.log.error('failed to create Core Node connection request', { error }); return next(error); } + + res.render('admin/core-node/connect-result'); } async getCoreNodeConnectForm (req, res) { diff --git a/app/models/core-node-request.js b/app/models/core-node-request.js index 1ca4a40..66f9a50 100644 --- a/app/models/core-node-request.js +++ b/app/models/core-node-request.js @@ -7,6 +7,11 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const RequestTokenSchema = new Schema({ + value: { type: String, required: true }, + claimed: { type: Boolean, default: false, required: true }, +}); + /* * Used for authenticating responses received and gathering performance and use * metrics for communications with Cores. @@ -22,10 +27,7 @@ const Schema = mongoose.Schema; const CoreNodeRequestSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1 }, core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' }, - token: { - value: { type: String, required: true }, - claimed: { type: Boolean, default: false, required: true }, - }, + token: { type: RequestTokenSchema }, method: { type: String, required: true }, url: { type: String, required: true }, response: { diff --git a/app/services/core-node.js b/app/services/core-node.js index 5888cdf..8fe67d3 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -108,6 +108,13 @@ class CoreNodeService extends SiteService { return core; } + async getConstellationStats ( ) { + const connectedCount = await CoreNode.countDocuments({ 'flags.isConnected': true }); + const pendingCount = await CoreNode.countDocuments({ 'flags.isConnected': false }); + const potentialReach = Math.round(Math.random() * 6000000); + return { connectedCount, pendingCount, potentialReach }; + } + async broadcast (request) { const results = [ ]; await CoreNode @@ -130,21 +137,34 @@ class CoreNodeService extends SiteService { try { const req = new CoreNodeRequest(); + const options = { + headers: { + 'Content-Type': 'application/json', + }, + }; + req.created = new Date(); req.core = core._id; - req.token = { - value: uuidv4(), - claimed: false, - }; - req.method = request.method || 'GET'; + if (request.tokenized) { + req.token = { + value: uuidv4(), + claimed: false, + }; + options.headers['X-DTP-Core-Token'] = req.token.value; + } + options.method = req.method = request.method || 'GET'; req.url = request.url; await req.save(); + if (request.body) { + options.body = JSON.stringify(request.body); + } + this.log.info('sending Core node request', { request: req }); - const response = await fetch(requestUrl, { - method: req.method, - body: request.body, - }); + const response = await fetch(requestUrl, options); + if (!response.ok) { + throw new SiteError(response.status, response.statusText); + } const json = await response.json(); /* diff --git a/app/services/csrf-token.js b/app/services/csrf-token.js index 08bba50..4c6b72b 100644 --- a/app/services/csrf-token.js +++ b/app/services/csrf-token.js @@ -43,7 +43,6 @@ class CsrfTokenService extends SiteService { this.log.info('claiming CSRF token', { requestToken, - ip: req.ip, }); await CsrfToken.updateOne( { _id: token._id }, diff --git a/app/services/dashboard.js b/app/services/dashboard.js new file mode 100644 index 0000000..eaaa9ae --- /dev/null +++ b/app/services/dashboard.js @@ -0,0 +1,276 @@ +// dashboard.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const User = mongoose.model('User'); +const ResourceVisit = mongoose.model('ResourceVisit'); + +const moment = require('moment'); + +const { SiteService } = require('../../lib/site-lib'); + +class DashboardService extends SiteService { + + static get CACHE_ENABLED ( ) { return process.env.LINKS_DASHBOARD_CACHE === 'enabled'; } + + constructor (dtp) { + super(dtp, module.exports); + } + + async getResourceVisitStats (resourceType, resourceId) { + if (!resourceId) { + throw new Error('Invalid resource'); + } + + // this will throw if not a valid ObjectId (or able to become one) + resourceId = mongoose.Types.ObjectId(resourceId); + + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:visit`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('generating resource visit stats report', { resourceId }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(3, 'day').toDate(); + + stats = await ResourceVisit.aggregate([ + { + $match: { + resource: resourceId, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + year: { $year: '$created' }, + month: { $month: '$created' }, + day: { $dayOfMonth: '$created' }, + hour: { $hour: '$created' }, + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + date: { + $dateFromParts: { + year: '$_id.year', + month: '$_id.month', + day: '$_id.day', + hour: '$_id.hour', + }, + }, + count: '$count', + }, + }, + { + $sort: { date: 1 }, + }, + ]); + + const response = { start: START_DATE, end: END_DATE, stats }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + + return response; + } + + /* + * + * RESOURCE COUNTRY STATS + * + */ + async getResourceCountryStats (resourceType, resourceId) { + const { cache: cacheService } = this.dtp.services; + let stats; + + // this will throw if not a valid ObjectId (or able to become one) + resourceId = mongoose.Types.ObjectId(resourceId); + + const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:country`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('generating resource country stats report', { resourceId }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(3, 'day').toDate(); + + stats = await ResourceVisit.aggregate([ + { + $match: { + resource: resourceId, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + count: '$count', + }, + }, + { + $sort: { count: -1, country: 1 }, + }, + { + $limit: 10, + }, + ]); + + const response = { start: START_DATE, end: END_DATE, stats }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + + return response; + } + + /* + * + * RESOURCE CITY STATS + * + */ + async getResourceCityStats (resourceType, resourceId) { + const { cache: cacheService } = this.dtp.services; + let stats; + + // this will throw if not a valid ObjectId (or able to become one) + resourceId = mongoose.Types.ObjectId(resourceId); + + const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:city`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('generating resource city stats report', { resourceId }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(3, 'day').toDate(); + + stats = await ResourceVisit.aggregate([ + { + $match: { + resource: resourceId, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + region: '$geoip.region', + city: '$geoip.city', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + region: '$_id.region', + city: '$_id.city', + count: '$count', + }, + }, + { + $sort: { count: -1, city: 1, region: 1, country: 1 }, + }, + { + $limit: 10, + }, + ]); + + const response = { start: START_DATE, end: END_DATE, stats }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + + return response; + } + + async getUserSignupsPerHour ( ) { + this.log.info('generating user signup stats report'); + + const start = moment().subtract(7, 'day').toDate(); + start.setHours(0, 0, 0, 0); + + const stats = await User.aggregate([ + { + $match: { + created: { $gt: start }, + }, + }, + { + $group: { + _id: { + year: { $year: '$created' }, + month: { $month: '$created' }, + day: { $dayOfMonth: '$created' }, + hour: { $hour: '$created' }, + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + date: { + $dateFromParts: { + year: '$_id.year', + month: '$_id.month', + day: '$_id.day', + hour: '$_id.hour', + }, + }, + count: '$count', + }, + }, + { + $sort: { date: 1 }, + }, + ]); + + return stats; + } +} + +module.exports = { + slug: 'dashboard', + name: 'dashboard', + create: (dtp) => { return new DashboardService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/oauth2.js b/app/services/oauth2.js index 90beeb0..720746b 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -166,12 +166,16 @@ class OAuth2Service extends SiteService { const client = new OAuth2Client(); client.created = NOW; client.updated = NOW; - client.node.owner = user._id; - client.node.name = striptags(clientDefinition.name.trim()); - client.node.domain = striptags(clientDefinition.domain.trim().toLowerCase()); - client.user = user._id; + + client.site.name = striptags(clientDefinition.name); + client.site.description = striptags(clientDefinition.description); + client.site.domain = striptags(clientDefinition.domain); + client.site.domainKey = striptags(clientDefinition.domainKey); + client.site.company = striptags(clientDefinition.company); + client.secret = generatePassword(PASSWORD_LEN, false); client.redirectURI = clientDefinition.redirectURI; + await client.save(); this.log.info('new OAuth2 client created', { diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index d3b723d..8f9a532 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -95,7 +95,6 @@ class OtpAuthService extends SiteService { this.log.debug('request on OTP-required route with no authentication', { service: serviceName, - ip: req.ip, session: res.locals.otpSession, }, req.user); diff --git a/app/views/admin/core-node/connect-result.pug b/app/views/admin/core-node/connect-result.pug new file mode 100644 index 0000000..fc697f3 --- /dev/null +++ b/app/views/admin/core-node/connect-result.pug @@ -0,0 +1,5 @@ +extends ../layouts/main +block content + + h1 Core Connect Response + pre= JSON.stringify(txConnect, null, 2) \ No newline at end of file diff --git a/app/views/admin/core-node/connect.pug b/app/views/admin/core-node/connect.pug index 3dc1da5..66b2226 100644 --- a/app/views/admin/core-node/connect.pug +++ b/app/views/admin/core-node/connect.pug @@ -4,15 +4,25 @@ block content form(method="POST", action="/admin/core-node/connect").uk-form .uk-card.uk-card-default.uk-card-small .uk-card-header - h1.uk-card-title Connect to New Core + h1.uk-card-title Connect to New Core Community .uk-card-body .uk-margin - label(for="host").uk-form-label Address - input(id="host", name="host", placeholder="Enter host name or address", required).uk-input + p You are registering #{site.name} with a DTP Core node. If accepted, this will enable members of that community to make use of the services provided by #{site.name} as an authenticated member of this site. + p Please make sure the information displayed is as you want it to be displayed in your Core Directory entry. + + .uk-margin + - var { version, name } = pkg; + label.uk-form-label Package Information + textarea(style="font-family: Courier New, fixed; font-size: 12px;", rows= 4, disabled).uk-textarea= JSON.stringify({ name, version }, null, 2) + + .uk-margin + label.uk-form-label Site Information + textarea(style="font-family: Courier New, fixed; font-size: 12px;", rows= 7, disabled).uk-textarea= JSON.stringify(site, null, 2) + .uk-margin - label(for="port").uk-form-label Port Number - input(id="port", name="port", min="1", max="65535", step="1", value="4200", required).uk-input + label(for="host").uk-form-label Core Host + input(id="host", name="host", placeholder="Enter host:port of Core to connect", required).uk-input .uk-card-footer button(type="submit").uk-button.uk-button-primary Send Request \ No newline at end of file diff --git a/app/views/admin/index.pug b/app/views/admin/index.pug index cb2d13e..6efb3d8 100644 --- a/app/views/admin/index.pug +++ b/app/views/admin/index.pug @@ -1,17 +1,30 @@ extends layouts/main block content + div(uk-grid) + div(class="uk-width-1-1 uk-width-auto@m") + h3= site.name + div(uk-grid).uk-flex-middle + .uk-width-auto + +renderCell('Members', formatCount(stats.memberCount)) + .uk-width-auto + +renderCell('Posts', formatCount(stats.postCount)) + .uk-width-auto + +renderCell('Comments', formatCount(stats.commentCount)) + div(class="uk-width-1-1 uk-width-auto@m") + h3 DTP Constellation + div(uk-grid).uk-flex-middle + .uk-width-auto + +renderCell('Potential Reach', formatCount(stats.constellation.potentialReach)) + .uk-width-auto + +renderCell('Connected Cores', formatCount(stats.constellation.connectedCount)) + .uk-width-auto + +renderCell('Pending Cores', formatCount(stats.constellation.pendingCount)) + .uk-margin + h3 Hourly Sign-Ups canvas(id="hourly-signups") - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - +renderCell('Members', formatCount(stats.memberCount)) - .uk-width-auto - +renderCell('Posts', formatCount(stats.postCount)) - .uk-width-auto - +renderCell('Comments', formatCount(stats.commentCount)) - block viewjs script(src="/chart.js/chart.min.js") script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js") diff --git a/app/views/admin/layouts/main.pug b/app/views/admin/layouts/main.pug index 812e6d3..47ef59e 100644 --- a/app/views/admin/layouts/main.pug +++ b/app/views/admin/layouts/main.pug @@ -15,8 +15,10 @@ block content-container .uk-container.uk-container-expand div(uk-grid) div(class="uk-width-1-1 uk-flex-last uk-width-auto@m uk-flex-first@m") - .uk-card.uk-card-secondary.uk-card-body.uk-border-rounded - include ../components/menu + .uk-card.uk-card-secondary.uk-border-rounded(style="overflow: hidden;") + img(src='/img/app/menu-topper.png').uk-width-medium + .uk-card-body.uk-border-rounded + include ../components/menu div(class="uk-width-1-1 uk-flex-first uk-width-expand@m").uk-width-expand block content diff --git a/app/views/components/library.pug b/app/views/components/library.pug index 8e5409f..a176279 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -11,9 +11,9 @@ include section-title } mixin renderCell (label, value, className) - div(style="padding: 10px 20px;", title=`${label}: ${numeral(value).format('0,0')}`).uk-card.uk-card-default.uk-card-body.no-select - .uk-text-muted= label + div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select div(class=className)= value + .uk-text-muted.uk-text-small= label - function displayIntegerValue (value) { diff --git a/client/img/app/menu-topper.png b/client/img/app/menu-topper.png new file mode 100644 index 0000000..7ded1aa Binary files /dev/null and b/client/img/app/menu-topper.png differ diff --git a/client/less/site/uikit-theme.dtp-dark.less b/client/less/site/uikit-theme.dtp-dark.less index afe7240..f337043 100644 --- a/client/less/site/uikit-theme.dtp-dark.less +++ b/client/less/site/uikit-theme.dtp-dark.less @@ -92,4 +92,11 @@ @internal-form-datalist-image: "/uikit/images/backgrounds/form-datalist.svg"; @internal-form-radio-image: "/uikit/images/backgrounds/form-radio.svg"; @internal-form-checkbox-image: "/uikit/images/backgrounds/form-checkbox.svg"; -@internal-form-checkbox-indeterminate-image: "/uikit/images/backgrounds/form-checkbox-indeterminate.svg"; \ No newline at end of file +@internal-form-checkbox-indeterminate-image: "/uikit/images/backgrounds/form-checkbox-indeterminate.svg"; + +/* Disabled */ +.uk-input:disabled, +.uk-select:disabled, +.uk-textarea:disabled { + color: @inverse-global-muted-color; +} \ No newline at end of file diff --git a/dtp-webapp-cli.js b/dtp-webapp-cli.js index a188691..8aff7d6 100644 --- a/dtp-webapp-cli.js +++ b/dtp-webapp-cli.js @@ -182,7 +182,11 @@ module.requestCoreConnect = async (host) => { const txConnect = await coreNodeService.sendRequest(core, { method: 'POST', - url: '/core/connect' + url: '/core/connect', + body: { + version: module.pkg.version, + site: module.config.site, + }, }); module.log.info('connect tranaction', { txConnect });