Browse Source

More post completeness; and a captcha for signup.

master
Rob Colbert 2 years ago
parent
commit
cf1a95e340
  1. 2
      app/controllers/admin.js
  2. 2
      app/controllers/admin/post.js
  3. 37
      app/controllers/post.js
  4. 20
      app/controllers/welcome.js
  5. 24
      app/models/comment.js
  6. 35
      app/models/lib/resource-stats.js
  7. 5
      app/models/post.js
  8. 30
      app/models/resource-view.js
  9. 11
      app/services/post.js
  10. 72
      app/services/resource.js
  11. 19
      app/views/admin/post/editor.pug
  12. 2
      app/views/components/back-button.pug
  13. 7
      app/views/components/library.pug
  14. 4
      app/views/index.pug
  15. 1
      app/views/layouts/main.pug
  16. 33
      app/views/post/view.pug
  17. 8
      app/views/welcome/signup.pug
  18. 2
      app/workers/newsletter.js
  19. BIN
      client/fonts/CodeBars.ttf
  20. BIN
      client/fonts/Dirty Sweb.ttf
  21. BIN
      client/fonts/Ink Studio.ttf
  22. BIN
      client/fonts/inkswipe.ttf
  23. 24
      client/js/site-admin-host-stats-app.js
  24. 9
      client/js/site-app.js
  25. 1
      package.json
  26. 2
      sites-start-local
  27. 13
      supervisord/dtp-sites.conf
  28. 13
      supervisord/host-services.conf
  29. 19
      yarn.lock

2
app/controllers/admin.js

@ -1,6 +1,6 @@
// admin.js
// Copyright (C) 2021 Digital Telepresence, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';

2
app/controllers/admin/post.js

@ -33,6 +33,8 @@ class PostController extends SiteController {
router.get('/', this.getIndex.bind(this));
router.delete('/:postId', this.deletePost.bind(this));
return router;
}

37
app/controllers/post.js

@ -42,32 +42,6 @@ class PostController extends SiteController {
);
}
async populatePostYear (req, res, next, postYear) {
try {
res.locals.postYear = parseInt(postYear, 10);
if (!res.locals.postYear || isNaN(res.locals.postYear)) {
throw new Error('Invalid post year');
}
return next();
} catch (error) {
this.log.error('failed to populate post year', { postYear, error });
return next(error);
}
}
async populatePostMonth (req, res, next, postMonth) {
try {
res.locals.postMonth = parseInt(postMonth, 10);
if (!res.locals.postMonth || isNaN(res.locals.postMonth) || (postMonth < 1) || (postMonth > 12)) {
throw new Error('Invalid post month');
}
return next();
} catch (error) {
this.log.error('failed to populate post month', { postMonth, error });
return next(error);
}
}
async populatePostSlug (req, res, next, postSlug) {
const { post: postService } = this.dtp.services;
try {
@ -82,8 +56,15 @@ class PostController extends SiteController {
}
}
async getView (req, res) {
res.render('post/view');
async getView (req, res, next) {
const { resource: resourceService } = this.dtp.services;
try {
await resourceService.recordView(req, 'Post', res.locals.post._id);
res.render('post/view');
} catch (error) {
this.log.error('failed to service post view', { postId: res.locals.post._id, error });
return next(error);
}
}
async getIndex (req, res, next) {

20
app/controllers/welcome.js

@ -6,7 +6,10 @@
const DTP_COMPONENT_NAME = 'welcome';
const path = require('path');
const express = require('express');
const captcha = require('svg-captcha');
const { SiteController/*, SiteError */ } = require('../../lib/site-lib');
@ -20,9 +23,12 @@ class WelcomeController extends SiteController {
const { limiter: limiterService } = this.dtp.services;
const welcomeLimiter = limiterService.create(limiterService.config.welcome);
captcha.loadFont(path.join(this.dtp.config.root, 'client', 'fonts', 'Dirty Sweb.ttf'));
const router = express.Router();
this.dtp.app.use('/welcome', welcomeLimiter, router);
router.get('/signup/captcha', this.getSignupCaptcha.bind(this));
router.get('/signup', this.getSignupView.bind(this));
router.get('/login', this.getLoginView.bind(this));
router.get('/', this.getHomeView.bind(this));
@ -30,7 +36,21 @@ class WelcomeController extends SiteController {
return router;
}
async getSignupCaptcha (req, res) {
const signupCaptcha = captcha(req.session.captcha.signup, {
color: false,
noise: 3,
width: 300,
height: 80,
});
res.set('Content-Type', 'image/svg+xml');
res.set('Content-Length', signupCaptcha.length);
res.status(200).send(signupCaptcha);
}
async getSignupView (req, res) {
req.session.captcha = req.session.captcha || { };
req.session.captcha.signup = captcha.randomText(4 + Math.floor(Math.random()*4));
res.render('welcome/signup');
}

24
app/models/comment.js

@ -0,0 +1,24 @@
// comment.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { CommentStats, CommentStatsDefaults } = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const CommentSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
resourceType: { type: String, enum: ['Post', 'Page', 'Newsletter'], required: true },
resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' },
replyTo: { type: Schema.ObjectId, index: 1, ref: 'Comment' },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
content: { type: String, required: true, maxlength: 3000 },
stats: { type: CommentStats, default: CommentStatsDefaults, required: true },
});
module.exports = mongoose.model('Comment', CommentSchema);

35
app/models/lib/resource-stats.js

@ -0,0 +1,35 @@
// lib/resource-stats.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
module.exports.ResourceStats = new Schema({
totalViewCount: { type: Number, default: 0, required: true },
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
commentCount: { type: Number, default: 0, required: true },
});
module.exports.ResourceStatsDefaults = {
totalViewCount: 0,
upvoteCount: 0,
downvoteCount: 0,
commentCount: 0,
};
module.exports.CommentStats = new Schema({
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
replyCount: { type: Number, default: 0, required: true },
});
module.exports.CommentStatsDefaults = {
upvoteCount: 0,
downvoteCount: 0,
replyCount: 0,
};

5
app/models/post.js

@ -4,10 +4,13 @@
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { ResourceStats, ResourceStatsDefaults } = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const POST_STATUS_LIST = ['draft','published','archived'];
const PostSchema = new Schema({
@ -20,7 +23,9 @@ const PostSchema = new Schema({
summary: { type: String, required: true },
content: { type: String, required: true, select: false },
status: { type: String, enum: POST_STATUS_LIST, default: 'draft', index: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
flags: {
enableComments: { type: Boolean, default: true, index: true },
isFeatured: { type: Boolean, default: false, index: true },
},
});

30
app/models/resource-view.js

@ -0,0 +1,30 @@
// resource-view.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const RESOURCE_TYPE_LIST = ['Page', 'Post', 'Newsletter'];
const ResourceViewSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' },
resourceType: { type: String, enum: RESOURCE_TYPE_LIST, required: true },
resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' },
uniqueKey: { type: String, required: true, index: 1 },
viewCount: { type: Number, default: 0, required: true },
});
ResourceViewSchema.index({
created: 1,
resourceType: 1,
resource: 1,
uniqueKey: 1,
}, {
name: 'res_view_daily_unique',
});
module.exports = mongoose.model('ResourceView', ResourceViewSchema);

11
app/services/post.js

@ -36,7 +36,11 @@ class PostService extends SiteService {
post.slug = this.createPostSlug(post._id, post.title);
post.summary = striptags(postDefinition.summary.trim());
post.content = postDefinition.content.trim();
post.status = 'draft';
post.status = postDefinition.status || 'draft';
post.flags = {
enableComments: postDefinition.enableComments === 'on',
isFeatured: postDefinition.isFeatured === 'on',
};
await post.save();
@ -65,9 +69,8 @@ class PostService extends SiteService {
updateOp.$set.status = striptags(postDefinition.status.trim());
}
if (Object.keys(updateOp.$set).length === 0) {
return; // no update to perform
}
updateOp.$set['flags.enableComments'] = postDefinition.enableComments === 'on';
updateOp.$set['flags.isFeatured'] = postDefinition.isFeatured === 'on';
await Post.updateOne(
{ _id: post._id },

72
app/services/resource.js

@ -0,0 +1,72 @@
// resource.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const { SiteService } = require('../../lib/site-lib');
const mongoose = require('mongoose');
const ResourceView = mongoose.model('ResourceView');
class ResourceService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateResourceView = [
{
path: 'resource',
},
];
}
/**
* Records 24-hour unique view counts for a given resource happening on a
* given ExpressJS Request. Views are uniqued by stripping time from the
* current Date, and upserting a tracking object in MongoDB.
*
* @param {Request} req
* @param {String} resourceType 'Post', 'Page', or 'Newsletter'
* @param {mongoose.Types.ObjectId} resourceId The _id of the object for which
* a view is being tracked.
*/
async recordView (req, resourceType, resourceId) {
const CURRENT_DAY = new Date();
CURRENT_DAY.setHours(0, 0, 0, 0);
let uniqueKey = req.ip.toString().trim().toLowerCase();
if (req.user) {
uniqueKey += `:user:${req.user._id.toString()}`;
}
const response = await ResourceView.updateOne(
{
created: CURRENT_DAY,
resourceType,
resource: resourceId,
uniqueKey,
},
{
$inc: { viewCount: 1 },
},
{ upsert: true },
);
this.log.debug('resource view', { response });
if (response.upsertedCount > 0) {
const Model = mongoose.model(resourceType);
await Model.updateOne(
{ _id: resourceId },
{
$inc: { 'stats.totalViewCount': 1 },
},
);
}
}
}
module.exports = {
slug: 'resource',
name: 'resource',
create: (dtp) => { return new ResourceService(dtp); },
};

19
app/views/admin/post/editor.pug

@ -5,12 +5,12 @@ block content
form(method="POST", action= actionUrl).uk-form
div(uk-grid).uk-grid-small
.uk-width-2-3
div(class="uk-width-1-1 uk-width-2-3@m")
.uk-margin
label(for="content").uk-form-label Post body
textarea(id="content", name="content", rows="4").uk-textarea= post ? post.content : undefined
.uk-width-1-3
div(class="uk-width-1-1 uk-width-1-3@m")
.uk-margin
label(for="title").uk-form-label Post title
input(id="title", name="title", type="text", placeholder= "Enter post title", value= post ? post.title : undefined).uk-input
@ -20,14 +20,22 @@ block content
div(uk-grid)
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary= post ? 'Update post' : 'Create post'
.uk-width-auto
a(href="/admin/post").uk-button.dtp-button-default Cancel
.uk-margin
label(for="status").uk-form-label Status
select(id="status", name="status").uk-select
option(value="draft", selected= post ? post.status === 'draft' : true) Draft
option(value="published", selected= post ? post.status === 'published' : false) Published
option(value="archived", selected= post ? post.status === 'archived' : true) Archived
option(value="archived", selected= post ? post.status === 'archived' : false) Archived
.uk-margin
div(uk-grid).uk-grid-small
.uk-width-auto
label
input(id="enable-comments", name="enableComments", type="checkbox", checked= post ? post.flags.enableComments : true).uk-checkbox
| Enable comments
.uk-width-auto
label
input(id="is-featured", name="isFeatured", type="checkbox", checked= post ? post.flags.isFeatured : true).uk-checkbox
| Featured
block viewjs
script(src="/tinymce/tinymce.min.js")
@ -61,7 +69,6 @@ block viewjs
{ title: 'Body Image', value: 'dtp-image-body' },
{ title: 'Title Image', value: 'dtp-image-title' },
],
document_base_url: '/post/#{post.slug}',
convert_urls: false,
});

2
app/views/components/back-button.pug

@ -1,4 +1,4 @@
button(type="button", onclick= "return window.history.back();").uk-button.dtp-button-primary
button(type="button", onclick= "return dtp.app.goBack();").uk-button.dtp-button-primary
span
i.fas.fa-chevron-left
span.uk-margin-small-left Back

7
app/views/components/library.pug

@ -11,4 +11,9 @@ include button-icon
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(class=className)= value
div(class=className)= value
-
function displayIntegerValue (value) {
return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0');
}

4
app/views/index.pug

@ -34,8 +34,8 @@ block content
img(src="/img/default-poster.jpg").responsive
.uk-width-2-3
article.uk-article
h4.uk-article-title= post.title
.uk-margin.uk-margin-small.uk-text-truncate= post.summary
h4(style="line-height: 1;").uk-article-title.uk-margin-remove= post.title
.uk-text-truncate= post.summary
.uk-article-meta
div(uk-grid).uk-grid-small
.uk-width-auto published: #{moment(post.created).format("MMM DD YYYY HH:MM a")}

1
app/views/layouts/main.pug

@ -92,6 +92,7 @@ html(lang='en')
if user
script.
window.dtp.user = !{JSON.stringify(safeUser, null, 2)}
window.dtp.domain = !{JSON.stringify(site.domain)}
if channel
script.

33
app/views/post/view.pug

@ -3,7 +3,7 @@ block content
include ../components/page-sidebar
section.uk-section.uk-section-default
section.uk-section.uk-section-default.uk-section-small
.uk-container
div(uk-grid)
.uk-width-2-3
@ -17,9 +17,38 @@ block content
a(href=`/admin/post/${post._id}`).uk-button.dtp-button-text EDIT
.uk-text-lead= post.summary
.uk-margin
.uk-article-meta= moment(post.created).format('MMM DD, YYYY [at] hh:mm a')
.uk-article-meta
div(uk-grid).uk-grid-small.uk-flex-top
.uk-width-expand
div published: #{moment(post.created).format('MMM DD, YYYY - hh:mm a').toUpperCase()}
.uk-width-auto
+renderButtonIcon('fa-eye', displayIntegerValue(post.stats.totalViewCount))
.uk-width-auto
+renderButtonIcon('fa-chevron-up', displayIntegerValue(post.stats.upvoteCount))
.uk-width-auto
+renderButtonIcon('fa-chevron-down', displayIntegerValue(post.stats.downvoteCount))
.uk-width-auto
+renderButtonIcon('fa-comment', displayIntegerValue(post.stats.commentCount))
.uk-margin
!= post.content
if post.updated
.uk-margin
.uk-article-meta This post was updated on #{moment(post.updated).format('MMMM DD, YYYY, [at] hh:mm a')}.
if post.flags.enableComments
.dtp-border-bottom
h3.uk-heading-bullet Comments
.uk-margin
form(method="POST", action=`/post/${post._id}/comment`, onsubmit="return dtp.app.submitForm(event);").uk-form
.uk-margin
textarea(id="content", name="content", rows="4", placeholder="Enter comment").uk-textarea
.uk-text-small
div(uk-grid).uk-flex-between
.uk-width-auto You are commenting as: #{user.username}
.uk-width-auto 0 of 3000
button(type="submit").uk-button.dtp-button-primary Post comment
.uk-width-1-3
+renderPageSidebar()

8
app/views/welcome/signup.pug

@ -39,6 +39,10 @@ block content
.uk-container.uk-container-small
.uk-margin-large
.uk-text-center
.uk-margin
.uk-width-medium.uk-margin-auto
div(style="background: white;")
img(src='/welcome/signup/captcha', style="padding: 8px 0;").uk-display-block.uk-margin-auto
input(id="captcha", name="captcha", type="text", placeholder="Enter captcha text").uk-input.uk-text-center
.uk-margin-small
button(type="submit").uk-button.uk-button-primary Create Account
.uk-text-center.uk-text-small.uk-text-muted You aren't going to be asked for money and I'm not going to bug you. This is only a technology demo.
button(type="submit").uk-button.dtp-button-primary Create Account

2
app/workers/newsletter.js

@ -1,6 +1,6 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';

BIN
client/fonts/CodeBars.ttf

Binary file not shown.

BIN
client/fonts/Dirty Sweb.ttf

Binary file not shown.

BIN
client/fonts/Ink Studio.ttf

Binary file not shown.

BIN
client/fonts/inkswipe.ttf

Binary file not shown.

24
client/js/site-admin-host-stats-app.js

@ -225,6 +225,30 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
UIkit.modal.alert(`Failed to delete newsletter: ${error.message}`);
}
}
async deletePost (event) {
const postId = event.currentTarget.getAttribute('data-post-id');
const postTitle = event.currentTarget.getAttribute('data-post-title');
console.log(postId, postTitle);
try {
await UIkit.modal.confirm(`Are you sure you want to delete "${postTitle}"`);
} catch (error) {
this.log.info('deletePost', 'aborted');
return;
}
try {
const response = await fetch(`/admin/post/${postId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete post');
}
await this.processResponse(response);
} catch (error) {
this.log.error('deletePost', 'failed to delete post', { postId, postTitle, error });
UIkit.modal.alert(`Failed to delete post: ${error.message}`);
}
}
}
dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp;

9
client/js/site-app.js

@ -137,6 +137,15 @@ export default class DtpSiteApp extends DtpApp {
this.chat.messageList.scrollTop = this.chat.messageList.scrollHeight;
}
async goBack ( ) {
if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) {
window.history.back();
} else {
window.location.href= '/';
}
return false;
}
async submitForm (event, userAction) {
event.preventDefault();
event.stopPropagation();

1
package.json

@ -64,6 +64,7 @@
"socket.io": "^4.4.0",
"socket.io-emitter": "^3.2.0",
"striptags": "^3.2.0",
"svg-captcha": "^1.4.0",
"systeminformation": "^5.9.16",
"tinymce": "^5.10.2",
"uikit": "^3.9.4",

2
sites-start-local

@ -4,7 +4,7 @@ MINIO_ROOT_USER="sites"
MINIO_ROOT_PASSWORD="1f1c7c9b-b833-4462-ae41-d56f52faa49c"
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
forever start app/workers/host-services.js
forever start --killSignal=SIGINT app/workers/host-services.js
minio server ./data/minio --console-address ":9001"

13
supervisord/dtp-sites.conf

@ -0,0 +1,13 @@
[program:dtp-sites]
numprocs=1
process_name=%(program_name)s_%(process_num)02d
command=/home/dtp/.nvm/versions/node/v16.13.0/bin/node --optimize_for_size --max_old_space_size=1024 --gc_interval=100 dtp-sites.js
directory=/home/dtp/live/dtp-sites
autostart=true
autorestart=true
startretries=3
stopsignal=INT
stderr_logfile=/var/log/dtp-sites/dtp-sites.err.log
stdout_logfile=/var/log/dtp-sites/dtp-sites.out.log
user=dtp
environment=HOME='/home/dtp/live/dtp-sites',HTTP_BIND_PORT=30%(process_num)02d,NODE_ENV=production,LOGNAME=dtp-sites

13
supervisord/host-services.conf

@ -0,0 +1,13 @@
[program:host-services]
numprocs=1
process_name=%(program_name)s_%(process_num)02d
command=/home/dtp/.nvm/versions/node/v16.13.0/bin/node --optimize_for_size --max_old_space_size=1024 --gc_interval=100 app/workers/host-services.js
directory=/home/dtp/live/dtp-sites
autostart=true
autorestart=true
startretries=3
stopsignal=INT
stderr_logfile=/var/log/dtp-sites/host-services.err.log
stdout_logfile=/var/log/dtp-sites/host-services.out.log
user=dtp
environment=HOME='/home/dtp/live/dtp-sites',HTTP_BIND_PORT=30%(process_num)02d,NODE_ENV=production,LOGNAME=host-services

19
yarn.lock

@ -5787,6 +5787,13 @@ onetime@^6.0.0:
dependencies:
mimic-fn "^4.0.0"
opentype.js@^0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/opentype.js/-/opentype.js-0.7.3.tgz#40fb8ce18bfd60e74448efdfe442834098397aab"
integrity sha1-QPuM4Yv9YOdESO/f5EKDQJg5eqs=
dependencies:
tiny-inflate "^1.0.2"
[email protected]:
version "1.1.1"
resolved "https://registry.yarnpkg.com/openurl/-/openurl-1.1.1.tgz#3875b4b0ef7a52c156f0db41d4609dbb0f94b387"
@ -7523,6 +7530,13 @@ sver-compat@^1.5.0:
es6-iterator "^2.0.1"
es6-symbol "^3.1.1"
svg-captcha@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/svg-captcha/-/svg-captcha-1.4.0.tgz#32ead3c6463936c218bb3bc9ed04fea4eeffe492"
integrity sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==
dependencies:
opentype.js "^0.7.3"
[email protected]:
version "1.0.1"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4"
@ -7653,6 +7667,11 @@ time-stamp@^1.0.0:
resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3"
integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=
tiny-inflate@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
tinymce@^5.10.2:
version "5.10.2"
resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.10.2.tgz#cf1ff01025909be26c64348509e6de8e70d58e1d"

Loading…
Cancel
Save