Browse Source

more link CRUD with DisplayEngine client control for the list

pull/1/head
Rob Colbert 3 years ago
parent
commit
098ca6439b
  1. 2
      app/controllers/auth.js
  2. 13
      app/controllers/home.js
  3. 2
      app/controllers/image.js
  4. 165
      app/controllers/link.js
  5. 2
      app/controllers/newsletter.js
  6. 11
      app/controllers/user.js
  7. 13
      app/models/lib/geo-types.js
  8. 35
      app/models/link-visit.js
  9. 1
      app/models/link.js
  10. 47
      app/services/link.js
  11. 9
      app/services/resource.js
  12. 8
      app/views/components/page-footer.pug
  13. 72
      app/views/index-logged-in.pug
  14. 3
      app/views/profile/home.pug
  15. 65
      client/js/site-app.js
  16. 18
      config/limiter.js

2
app/controllers/auth.js

@ -24,7 +24,7 @@ class AuthController extends SiteController {
async start ( ) {
const { limiter: limiterService } = this.dtp.services;
const upload = multer({ });
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
this.dtp.app.use('/auth', router);

13
app/controllers/home.js

@ -30,8 +30,6 @@ class HomeController extends SiteController {
return next();
});
router.post('/link', this.postCreateLink.bind(this));
router.get('/:username',
limiterService.create(limiterService.config.home.getPublicProfile),
this.getPublicProfile.bind(this),
@ -54,17 +52,6 @@ class HomeController extends SiteController {
}
}
async postCreateLink (req, res, next) {
const { link: linkService } = this.dtp.services;
try {
res.locals.link = await linkService.create(req.user, req.body);
res.redirect('/');
} catch (error) {
this.log.error('failed to create link', { error });
return next(error);
}
}
async getPublicProfile (req, res, next) {
const { link: linkService } = this.dtp.services;
try {

2
app/controllers/image.js

@ -29,7 +29,7 @@ class ImageController extends SiteController {
dtp.app.use('/image', router);
const imageUpload = multer({
dest: '/tmp/dtp-sites/upload/image',
dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}`,
limits: {
fileSize: 1024 * 1000 * 12,
},

165
app/controllers/link.js

@ -0,0 +1,165 @@
// link.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'link';
const express = require('express');
const multer = require('multer');
const { SiteController } = require('../../lib/site-lib');
class LinkController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
dtp.app.use('/link', router);
router.param('linkId', this.populateLinkId.bind(this));
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
return next();
});
router.post('/visit/:linkId',
limiterService.create(limiterService.config.link.postCreateLinkVisit),
this.postCreateLinkVisit.bind(this),
);
router.post('/sort',
limiterService.create(limiterService.config.link.postSortLinksList),
this.postSortLinksList.bind(this),
);
router.post('/:linkId',
limiterService.create(limiterService.config.link.postUpdateLink),
upload.none(),
this.postUpdateLink.bind(this),
);
router.post('/', this.postCreateLink.bind(this));
router.delete('/:linkId', this.deleteLink.bind(this));
}
async populateLinkId (req, res, next, linkId) {
const { link: linkService } = this.dtp.services;
try {
res.locals.link = await linkService.getById(linkId);
return next();
} catch (error) {
this.log.error('failed to populate link ID', { linkId, error });
return next(error);
}
}
async postCreateLinkVisit (req, res, next) {
const { link: linkService, resource: resourceService } = this.dtp.services;
try {
/*
* Do these jobs in parallel so the total work gets done faster
*/
const jobs = [
linkService.recordVisit(res.locals.link, req),
resourceService.recordView(req, 'Link', res.locals.link._id),
];
await Promise.all(jobs); // we don't care about any specific results
res.redirect(res.locals.link.href); // off you go!
} catch (error) {
this.log.error('failed to create link', { error });
return next(error);
}
}
async postSortLinksList (req, res) {
const { link: linkService, displayEngine: displayEngineService } = this.dtp.services;
try {
const displayList = displayEngineService.createDisplayList('sort-links-list');
await linkService.setItemOrder(req.body.updateOps);
displayList.showNotification(
'List sort order updated',
'success',
'bottom-center',
2000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to create link', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postUpdateLink (req, res) {
const { link: linkService, displayEngine: displayEngineService } = this.dtp.services;
try {
const displayList = displayEngineService.createDisplayList('update-link');
await linkService.update(res.locals.link, req.body);
displayList.showNotification(
'Link updated',
'success',
'bottom-center',
2000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to create link', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postCreateLink (req, res, next) {
const { link: linkService } = this.dtp.services;
try {
res.locals.link = await linkService.create(req.user, req.body);
res.redirect('/');
} catch (error) {
this.log.error('failed to create link', { error });
return next(error);
}
}
async deleteLink (req, res, next) {
const { link: linkService, displayEngine: displayEngineService } = this.dtp.services;
try {
const displayList = displayEngineService.createDisplayList('update-link');
await linkService.remove(res.locals.link);
displayList.removeElement(`li[data-link-id="${res.locals.link._id}"]`);
displayList.showNotification(
'Link removed',
'success',
'bottom-center',
2000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to remove link', { linkId: res.locals.link._id, error });
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new LinkController(dtp);
return controller;
};

2
app/controllers/newsletter.js

@ -21,7 +21,7 @@ class NewsletterController extends SiteController {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: '/tmp' });
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
dtp.app.use('/newsletter', router);

11
app/controllers/user.js

@ -20,12 +20,17 @@ class UserController extends SiteController {
async start ( ) {
const { dtp } = this;
const { limiter: limiterService, otpAuth: otpAuthService } = dtp.services;
const {
limiter: limiterService,
otpAuth: otpAuthService,
session: sessionService,
} = dtp.services;
const upload = multer({ dest: "/tmp" });
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
dtp.app.use('/user', router);
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const otpMiddleware = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: false,
@ -66,12 +71,14 @@ class UserController extends SiteController {
router.get('/:userId/settings',
limiterService.create(limiterService.config.user.getSettings),
authRequired,
otpMiddleware,
checkProfileOwner,
this.getUserSettingsView.bind(this),
);
router.get('/:userId',
limiterService.create(limiterService.config.user.getUserProfile),
authRequired,
otpMiddleware,
checkProfileOwner,
this.getUserView.bind(this),

13
app/models/lib/geo-types.js

@ -0,0 +1,13 @@
// geo-types.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
module.exports.GeoPoint = new Schema({
type: { type: String, enum: ['String'], default: 'String', required: true },
coordinates: { type: [Number], required: true },
});

35
app/models/link-visit.js

@ -0,0 +1,35 @@
// link-visit.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { GeoPoint } = require('./lib/geo-types');
const LinkVisitSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' },
link: { type: Schema.ObjectId, required: true, index: 1, ref: 'Link' },
user: { type: Schema.ObjectId, index: 1, ref: 'User' },
geoip: {
country: { type: String },
region: { type: String },
eu: { type: String },
timezone: { type: String },
city: { type: String },
location: { type: GeoPoint },
},
});
LinkVisitSchema.index({
user: 1,
}, {
partialFilterExpression: {
user: { $exists: true },
},
name: 'link_visits_for_user',
});
module.exports = mongoose.model('LinkVisit', LinkVisitSchema);

1
app/models/link.js

@ -15,6 +15,7 @@ const LinkSchema = new Schema({
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
label: { type: String, required: true, maxlength: 100 },
href: { type: String, required: true, maxlength: 255 },
order: { type: Number, default: 0, required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
});

47
app/services/link.js

@ -5,11 +5,15 @@
'use strict';
const mongoose = require('mongoose');
const Link = mongoose.model('Link');
const LinkVisit = mongoose.model('LinkVisit');
const geoip = require('geoip-lite');
const striptags = require('striptags');
const { SiteService } = require('../../lib/site-lib');
const link = require('../models/link');
class LinkService extends SiteService {
@ -62,6 +66,7 @@ class LinkService extends SiteService {
async getForUser (user, pagination) {
const links = await Link
.find({ user: user._id })
.sort({ order: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
@ -69,7 +74,47 @@ class LinkService extends SiteService {
}
async remove (link) {
await Link.deleteOne({ _id: link._id });
this.log.debug('removing link visit records', { link: link._id });
await LinkVisit.deleteMany({ link: link._id });
const { resource: resourceService } = this.dtp.services;
await resourceService.remove('Link', link._id);
}
async recordVisit (link, req) {
const NOW = new Date();
const visit = new LinkVisit();
visit.created = NOW;
visit.link = link._id;
if (req.user) {
visit.user = req.user._id;
}
/*
* We geo-analyze (but do not store) the IP address.
*/
const geo = geoip.lookup(req.ip);
if (geo) {
visit.geoip = {
country: geo.country,
region: geo.region,
eu: geo.eu,
timezone: geo.timezone,
city: geo.city,
location: geo.ll,
};
}
await visit.save();
}
async setItemOrder (links) {
for await (const link of links) {
this.log.debug('set link order', { link });
await Link.updateOne({ _id: link._id }, { $set: { order: link.order } });
}
}
}

9
app/services/resource.js

@ -63,6 +63,15 @@ class ResourceService extends SiteService {
);
}
}
async remove (resourceType, resource) {
this.log.debug('removing resource view records', { resourceType, resourceId: resource._id });
await ResourceView.deleteMany({ resource: resource._id });
this.log.debug('removing resource', { resourceType, resourceId: resource._id });
const Model = mongoose.model(resourceType);
await Model.deleteOne({ _id: resource._id });
}
}
module.exports = {

8
app/views/components/page-footer.pug

@ -1,6 +1,6 @@
section.uk-section.uk-section-default.uk-section-small.dtp-site-footer
.uk-container.uk-text-small.uk-text-center
.uk-margin
.uk-margin-large
.uk-text-large.uk-text-bold Support #{site.name}
div(uk-grid).uk-grid-small.uk-flex-center.uk-flex-middle
.uk-width-auto
@ -11,9 +11,9 @@ section.uk-section.uk-section-default.uk-section-small.dtp-site-footer
a(href="litecoin:LM735557AfR8BWN1sFBezC7WexXaC5HfZd?message=LibertyLinks%20Donation") Litecoin (LTC)
.uk-width-auto
a(href="monero:43vJtTb9gXwRxmrKRYTNPZYHtkrzzyKsR35Ak3kUZqV9CuTBVWVsxo77VXQHLELJnMFcuwuQakwGp2rWRGUTs4esEGK23v2?tx_description=LibertyLinks%20Donation") Monero (XMR)
.uk-margin
.uk-margin-small
span Copyright © #{moment().format('YYYY')}
span
+renderSiteLink()
.uk-margin
div #[a(href="https://owenbenjamin.com") Owen Benjamin] named this website.
.uk-margin-small
div #[a(href="https://owenbenjamin.com").uk-link-reset Owen Benjamin] named this website.

72
app/views/index-logged-in.pug

@ -1,6 +1,26 @@
extends layouts/main
block content
mixin renderLinkEditor (editorId, link)
form(
method="POST",
data-editor-id= editorId,
action= link ? `/link/${link._id}` : '/link',
onsubmit=`return dtp.app.submitLinkForm(event, ${link ? 'update link' : 'create link'});`,
).uk-form
.uk-margin
label(for="label").uk-form-label Label
input(id="label", name="label", type="text", placeholder="Enter link label/title", value= link ? link.label : undefined).uk-input
.uk-margin
label(for="href").uk-form-label URL
input(id="href", name="href", type="text", placeholder="Enter link URL", value= link ? link.href : undefined).uk-input
div(uk-grid).uk-grid-small
.uk-width-auto
button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-default Cancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary
+renderButtonIcon('fa-plus', link ? 'Update link' : 'Add link')
section.uk-section.uk-section-default
.uk-container.uk-width-xlarge
.uk-margin
@ -8,29 +28,43 @@ block content
.uk-width-expand
h3.uk-heading-bullet.uk-margin-small Your links
.uk-width-auto
button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-primary.uk-button-small Add Link
button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-primary.uk-button-small
+renderButtonIcon('fa-plus', 'Add Link')
.uk-margin
#link-editor(hidden).uk-card.uk-card-secondary.uk-card-small
.uk-card-body
form(method="POST", action="/link").uk-form
.uk-margin
label(for="label").uk-form-label Label
input(id="label", name="label", type="text", placeholder="Enter link label/title").uk-input
.uk-margin
label(for="href").uk-form-label URL
input(id="href", name="href", type="text", placeholder="Enter link URL").uk-input
div(uk-grid).uk-grid-small
.uk-width-auto
button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-default Cancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary Add link
#link-editor(hidden).uk-card.uk-card-secondary.uk-card-small.uk-card-body
+renderLinkEditor('#link-editor')
.uk-margin
if Array.isArray(links) && (links.length > 0)
ul.uk-list
ul#links-list.uk-list
each link in links
li
a(href= link.href).uk-button.dtp-button-primary.uk-display-block= link.label
li(data-link-id= link._id, data-link-label= link.label)
div(uk-grid).uk-grid-small
.uk-width-expand
a(href= link.href).uk-button.dtp-button-primary.uk-button-small.uk-border-rounded= link.label
.uk-width-auto
button(type="button", uk-toggle={ target: `#link-editor-${link._id}` }).uk-button.dtp-button-default.uk-button-small
span
i.fas.fa-pen
.uk-width-auto
button(
type="submit",
data-link-id= link._id,
data-link-label= link.label,
onclick="return dtp.app.deleteLink(event);",
).uk-button.dtp-button-danger.uk-button-small.uk-border-rounded
span
i.fas.fa-trash
div(id= `link-editor-${link._id}`, hidden).uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body
+renderLinkEditor(`#link-editor-${link._id}`, link)
else
div You have no links.
div You have no links.
block viewjs
script.
window.addEventListener('dtp-load', ( ) => {
dtp.app.attachLinksListManager();
});

3
app/views/profile/home.pug

@ -5,7 +5,8 @@ block content
ul.uk-list
each link in links
li
a(href= link.href).uk-button.dtp-button-primary.uk-display-block.uk-border-rounded= link.label
form(method="POST", action=`/link/visit/${link._id}`).uk-form.uk-display-block.uk-width-1-1
button(type="submit").uk-button.dtp-button-primary.uk-display-block.uk-border-rounded.uk-width-1-1= link.label
block dtp-navbar
block dtp-off-canvas

65
client/js/site-app.js

@ -494,6 +494,71 @@ export default class DtpSiteApp extends DtpApp {
async onEmojiSelected (selection) {
this.emojiTargetElement.value += selection.emoji;
}
async attachLinksListManager ( ) {
const ELEM_ID = '#links-list';
this.linksList = UIkit.sortable(ELEM_ID);
UIkit.util.on(ELEM_ID, 'stop', this.updateLinkOrders.bind(this));
}
async submitLinkForm (event, eventName) {
await this.submitForm(event, eventName);
const editorId = event.target.getAttribute('data-editor-id');
document.querySelector(editorId).setAttribute('hidden', '');
return true;
}
async updateLinkOrders ( ) {
const list = document.querySelectorAll('ul#links-list li[data-link-id]');
let order = 0;
const updateOps = [ ];
list.forEach((item) => {
const _id = item.getAttribute('data-link-id');
updateOps.push({ _id, order: order++ });
});
this.log.info('view', 'links list update', { updateOps });
try {
const response = await fetch(`/link/sort`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ updateOps }),
});
dtp.app.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to update list order: ${error.message}`);
}
}
async deleteLink (event) {
const target = event.currentTarget || event.target;
const link = {
_id: target.getAttribute('data-link-id'),
label: target.getAttribute('data-link-label'),
};
/*
* Prompt for delete confirmation
*/
try {
await UIkit.modal.confirm(`You are deleting: ${link.label}. This will remove "${link.label}" and it's stats from your profile and dashboard.`);
} catch (error) {
return; // canceled
}
try {
this.log.debug('deleteLink', 'deleting link', { link });
const response = await fetch(`/link/${link._id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to delete link: ${error.message}`);
}
}
}
dtp.DtpSiteApp = DtpSiteApp;

18
config/limiter.js

@ -97,6 +97,24 @@ module.exports = {
},
},
link: {
postCreateLinkVisit: {
total: 30,
expire: ONE_MINUTE,
message: 'You are visiting links too quickly',
},
postUpdateLink: {
total: 20,
expire: ONE_MINUTE,
message: 'You are editing links too quickly',
},
postSortLinksList: {
total: 40,
expire: ONE_MINUTE,
message: 'You are sorting links too quickly',
},
},
/*
* ManifestController
*/

Loading…
Cancel
Save