diff --git a/app/controllers/auth.js b/app/controllers/auth.js index b33b311..81d7732 100644 --- a/app/controllers/auth.js +++ b/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); diff --git a/app/controllers/home.js b/app/controllers/home.js index 8246d33..7cd6326 100644 --- a/app/controllers/home.js +++ b/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 { diff --git a/app/controllers/image.js b/app/controllers/image.js index 0dcfb66..d3acd73 100644 --- a/app/controllers/image.js +++ b/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, }, diff --git a/app/controllers/link.js b/app/controllers/link.js new file mode 100644 index 0000000..855d6b3 --- /dev/null +++ b/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; +}; \ No newline at end of file diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index 8676303..df0331e 100644 --- a/app/controllers/newsletter.js +++ b/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); diff --git a/app/controllers/user.js b/app/controllers/user.js index abeca5e..99b08b5 100644 --- a/app/controllers/user.js +++ b/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), diff --git a/app/models/lib/geo-types.js b/app/models/lib/geo-types.js new file mode 100644 index 0000000..5d1b62a --- /dev/null +++ b/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 }, +}); \ No newline at end of file diff --git a/app/models/link-visit.js b/app/models/link-visit.js new file mode 100644 index 0000000..65bf677 --- /dev/null +++ b/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); \ No newline at end of file diff --git a/app/models/link.js b/app/models/link.js index cba0dd6..468889a 100644 --- a/app/models/link.js +++ b/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 }, }); diff --git a/app/services/link.js b/app/services/link.js index 2c2dc08..1e237f4 100644 --- a/app/services/link.js +++ b/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 } }); + } } } diff --git a/app/services/resource.js b/app/services/resource.js index d07536d..ef3b8a0 100644 --- a/app/services/resource.js +++ b/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 = { diff --git a/app/views/components/page-footer.pug b/app/views/components/page-footer.pug index 22039cf..b1437a7 100644 --- a/app/views/components/page-footer.pug +++ b/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. \ No newline at end of file + .uk-margin-small + div #[a(href="https://owenbenjamin.com").uk-link-reset Owen Benjamin] named this website. \ No newline at end of file diff --git a/app/views/index-logged-in.pug b/app/views/index-logged-in.pug index fc749f1..b57c006 100644 --- a/app/views/index-logged-in.pug +++ b/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. \ No newline at end of file + div You have no links. + +block viewjs + script. + window.addEventListener('dtp-load', ( ) => { + dtp.app.attachLinksListManager(); + }); \ No newline at end of file diff --git a/app/views/profile/home.pug b/app/views/profile/home.pug index b6bf60d..b071dca 100644 --- a/app/views/profile/home.pug +++ b/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 diff --git a/client/js/site-app.js b/client/js/site-app.js index 72d1e68..b3025f0 100644 --- a/client/js/site-app.js +++ b/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; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 6222b8f..f36aacc 100644 --- a/config/limiter.js +++ b/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 */