diff --git a/src/index.js b/src/index.js index b00a074..ed0c0f0 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ const path = require('path') _CC._ = require('lodash') _CC.moment = require('moment/min/moment-with-locales') +const { WishlistManager } = require('./structures/WishlistManager') const config = require('./config') _CC.config = config @@ -55,6 +56,9 @@ app.set('base', config.base) app.set('trust proxy', config.trustProxy) const db = new PouchDB('users') +_CC.usersDb = db + +_CC.wishlistManager = new WishlistManager() passport.use('local', new LocalStrategy( (username, password, done) => { diff --git a/src/languages/en-us.js b/src/languages/en-us.js index 12ac3f6..6353daa 100644 --- a/src/languages/en-us.js +++ b/src/languages/en-us.js @@ -140,10 +140,13 @@ module.exports.strings = { WISHLIST_ADD: 'Add item to wishlist', WISHLIST_ADDED_BY_USER: addedBy => `Added by: ${addedBy}`, WISHLIST_ADDED_BY: 'Added By', + WISHLIST_ADDED_ITEM_TO_OWN_WISHLIST: 'Added item to wishlist.', WISHLIST_CONFLICT: 'Items are being added too quickly. Please try again.', WISHLIST_DELETE: 'Delete', WISHLIST_EDIT_ITEM: 'Edit Item', + WISHLIST_FETCH_FAIL: 'Failed to fetch the wishlist -- does the user exist?', WISHLIST_IMAGE: 'Image', + WISHLIST_ITEM_MISSING: 'Failed to find item', WISHLIST_MOVE_DOWN: 'Move Down', WISHLIST_MOVE_GUARD: 'Not correct user', WISHLIST_MOVE_INVALID: 'Invalid move', @@ -152,6 +155,7 @@ module.exports.strings = { WISHLIST_MOVE_ITEM_UP: 'Move Item Up', WISHLIST_MOVE_SUCCESS: 'Successfully moved item!', WISHLIST_MOVE_TOP: 'Move Top', + WISHLIST_MOVE_UNKNOWN_DIRECTION: 'Unknown direction', WISHLIST_MOVE_UP: 'Move Up', WISHLIST_NAME: 'Name', WISHLIST_NOTE: 'Note', @@ -162,17 +166,16 @@ module.exports.strings = { WISHLIST_PLEDGE: 'Pledge', WISHLIST_PLEDGED: pledgedBy => `Pledged for by ${pledgedBy}`, WISHLIST_PLEDGED_GUEST: 'Pledged for by a guest user', + WISHLIST_PLEDGED_ITEM_FOR_USER: user => `Pledged item for ${user}.`, WISHLIST_PRICE: 'Price', WISHLIST_REFRESH_GUARD: 'Invalid user', WISHLIST_REFRESH_NO_URL: 'Item has no URL.', WISHLIST_REFRESH_SUCCESS: 'Successfully refreshed data!', WISHLIST_REMOVE_GUARD: 'Not correct user', - WISHLIST_REMOVE_MISSING: 'Failed to find item', WISHLIST_REMOVE_SUCCESS: 'Successfully removed from wishlist', WISHLIST_SUGGEST: 'Suggest item', WISHLIST_TITLE: name => `${_CC.config.siteTitle} - Wishlist - ${name}`, WISHLIST_UNPLEDGE_GUARD: 'You did not pledge for this', // should never happen unless someone makes their own http requests - WISHLIST_UNPLEDGE_MISSING: 'Failed to find item', WISHLIST_UNPLEDGE_SUCCESS: 'Successfully unpledged for item!', WISHLIST_UNPLEDGE: 'Unpledge', WISHLIST_URL_LABEL: 'Item URL or Name (Supported Sites)', diff --git a/src/routes/adminSettings/index.js b/src/routes/adminSettings/index.js index 87d6cbc..6ad5bca 100644 --- a/src/routes/adminSettings/index.js +++ b/src/routes/adminSettings/index.js @@ -29,19 +29,19 @@ module.exports = ({ db, ensurePfp }) => { const username = req.body.newUserUsername.trim() if (!username) { return db - .allDocs({ include_docs: true }) - .then((docs) => { - res.render("adminSettings", { - add_user_error: _CC.lang( - "ADMIN_SETTINGS_USERS_ADD_ERROR_USERNAME_EMPTY" - ), - title: _CC.lang("ADMIN_SETTINGS_HEADER"), - users: docs.rows, - }); + .allDocs({ include_docs: true }) + .then((docs) => { + res.render('adminSettings', { + add_user_error: _CC.lang( + 'ADMIN_SETTINGS_USERS_ADD_ERROR_USERNAME_EMPTY' + ), + title: _CC.lang('ADMIN_SETTINGS_HEADER'), + users: docs.rows }) - .catch((err) => { - throw err; - }); + }) + .catch((err) => { + throw err + }) } await db.put({ @@ -123,6 +123,8 @@ module.exports = ({ db, ensurePfp }) => { await db.bulkDocs(usersBulk) await db.remove(await db.get(oldName)) + await _CC.wishlistManager.clearCache() + req.flash('success', _CC.lang('ADMIN_SETTINGS_USERS_EDIT_RENAMED_USER')) return res.redirect(`/wishlist/${newName}`) } catch (error) { @@ -193,24 +195,33 @@ module.exports = ({ db, ensurePfp }) => { }) router.post('/edit/remove/:userToRemove', verifyAuth(), async (req, res) => { - if (!req.user.admin) return res.redirect('/') - const doc = await db.get(req.params.userToRemove) - if (doc.admin) { - req.flash('error', _CC.lang('ADMIN_SETTINGS_USERS_EDIT_DELETE_FAIL_ADMIN')) - return res.redirect('/admin-settings') - } - await db.remove(doc) - const { rows } = await db.allDocs({ include_docs: true }) - for (let i = 0; i < rows.length; i++) { - for (let j = 0; j < rows[i].doc.wishlist.length; j++) { - if (rows[i].doc.wishlist[j].pledgedBy === req.params.userToRemove) { - rows[i].doc.wishlist[j].pledgedBy = undefined - if (rows[i].doc.wishlist[j].addedBy === req.params.userToRemove) rows[i].doc.wishlist.splice(j, 1) - await db.put(rows[i].doc) + try { + if (!req.user.admin) return res.redirect('/') + + const userToRemove = await db.get(req.params.userToRemove) + if (userToRemove.admin) { + req.flash('error', _CC.lang('ADMIN_SETTINGS_USERS_EDIT_DELETE_FAIL_ADMIN')) + return res.redirect('/admin-settings') + } + await db.remove(userToRemove) + + const { rows } = await db.allDocs() + for (const row of rows) { + const wishlist = await _CC.wishlistManager.get(row.id) + for (const item of wishlist.items) { + if (item.addedBy === userToRemove._id) { + await wishlist.remove(item.id) + } else if (item.pledgedBy === userToRemove._id) { + await wishlist.unpledge(item.id) + } } } + + req.flash('success', _CC.lang('ADMIN_SETTINGS_USERS_EDIT_DELETE_SUCCESS', req.params.userToRemove)) + } catch (error) { + req.flash('error', `${error}`) } - req.flash('success', _CC.lang('ADMIN_SETTINGS_USERS_EDIT_DELETE_SUCCESS', req.params.userToRemove)) + res.redirect('/admin-settings') }) @@ -221,11 +232,17 @@ module.exports = ({ db, ensurePfp }) => { router.post('/clear-wishlists', verifyAuth(), async (req, res) => { if (!req.user.admin) return res.redirect('/') - const { rows } = await db.allDocs({ include_docs: true }) - for (const row of rows) { - row.doc.wishlist = [] - await db.put(row.doc) + + const usersBulk = [] + const { rows: users } = await db.allDocs({ include_docs: true }) + for (const { doc: user } of users) { + user.wishlist = [] + usersBulk.push(user) } + await db.bulkDocs(usersBulk) + + await _CC.wishlistManager.clearCache() + req.flash('success', _CC.lang('ADMIN_SETTINGS_CLEARDB_SUCCESS')) res.redirect('/admin-settings') }) diff --git a/src/routes/api/wishlist/index.js b/src/routes/api/wishlist/index.js index 3fcd42b..06adc01 100644 --- a/src/routes/api/wishlist/index.js +++ b/src/routes/api/wishlist/index.js @@ -9,31 +9,27 @@ module.exports = ({ db }) => { }) }) - router.post('/:user/:id/move/:direction', async (req, res) => { + router.post('/:user/:itemId/move/:direction', async (req, res) => { try { - if (req.user._id !== req.params.user) return res.json({ error: 'Not correct user' }) - const doc = await db.get(req.user._id) - const wishlist = doc.wishlist - if (req.params.direction === 'up') wishlist.reverse() - let moveFromIndex - wishlist.forEach(wish => { - if (wish.id === req.params.id) moveFromIndex = wishlist.indexOf(wish) - }) - const moveToIndex = wishlist.findIndex(wish => { - return (wishlist.indexOf(wish) > moveFromIndex && wish.addedBy === req.user._id) - }) - if (moveToIndex < 0 || moveToIndex > wishlist.length) return res.send({ error: 'Invalid move ' }) - const original = wishlist[moveToIndex] - wishlist[moveToIndex] = wishlist[moveFromIndex] - wishlist[moveFromIndex] = original - if (req.params.direction === 'up') wishlist.reverse() - doc.wishlist = wishlist - await db.put(doc) - res.send({ error: false }) + if (req.user._id !== req.params.user) { + throw new Error(_CC.lang('WISHLIST_MOVE_GUARD')) + } + + const wishlist = await _CC.wishlistManager.get(req.params.user) + if (req.params.direction === 'top') { + await wishlist.moveTop(req.params.itemId) + } else if (req.params.direction === 'up') { + await wishlist.move(req.params.itemId, -1) + } else if (req.params.direction === 'down') { + await wishlist.move(req.params.itemId, 1) + } else { + throw new Error(_CC.lang('WISHLIST_MOVE_UNKNOWN_DIRECTION')) + } } catch (error) { - console.error(error) - res.send({ error: error.message }) + return res.send({ error: error.message }) } + + res.send({ error: false }) }) return router diff --git a/src/routes/wishlist/index.js b/src/routes/wishlist/index.js index 5c6a1ff..3e8056a 100644 --- a/src/routes/wishlist/index.js +++ b/src/routes/wishlist/index.js @@ -1,11 +1,8 @@ const createDOMPurify = require('dompurify') const express = require('express') -const getProductName = require('get-product-name') const { JSDOM } = require('jsdom') const marked = require('marked') -const u64 = require('u64') -const config = require('../../config') const publicRoute = require('../../middlewares/publicRoute') const verifyAuth = require('../../middlewares/verifyAuth') @@ -22,21 +19,11 @@ const totals = wishlist => { return { unpledged, pledged } } -const ValidURL = (string) => { // Ty SO - try { - const url = new URL(string) - if (global._CC.config.wishlist.smile) { - if (url.hostname === 'www.amazon.com') url.hostname = 'smile.amazon.com' - } - if (url) return url - } catch (_) { - return false - } -} - module.exports = (db) => { const router = express.Router() + const wishlistManager = _CC.wishlistManager + router.get('/', publicRoute(), async (req, res) => { const docs = await db.allDocs({ include_docs: true }) if (global._CC.config.wishlist.singleList) { @@ -47,33 +34,31 @@ module.exports = (db) => { res.render('wishlists', { title: _CC.lang('WISHLISTS_TITLE'), users: docs.rows, totals }) }) - router.get('/:user', publicRoute(), async (req, res) => { - try { - const dbUser = await db.get(req.params.user) - if (global._CC.config.wishlist.singleList) { - if (!dbUser.admin) { - const docs = await db.allDocs({ include_docs: true }) - for (const row of docs.rows) { - if (row.doc.admin) return res.redirect(`/wishlist/${row.doc._id}`) - } + async function redirectIfSingleUserMode (req, res, next) { + const dbUser = await db.get(req.params.user) + if (_CC.config.wishlist.singleList) { + if (!dbUser.admin) { + const docs = await db.allDocs({ include_docs: true }) + for (const row of docs.rows) { + if (row.doc.admin) return res.redirect(`/wishlist/${row.doc._id}`) } } - const firstCanSee = dbUser.wishlist.findIndex(element => (element.addedBy === req.params.user)) - const wishlistReverse = [...dbUser.wishlist].reverse() - const lastCanSeeValue = wishlistReverse.find(element => (element.addedBy === req.params.user)) - const lastCanSee = dbUser.wishlist.indexOf(lastCanSeeValue) - for (const item of dbUser.wishlist) { - if (global._CC.config.wishlist.note.markdown) item.note = DOMPurify.sanitize(marked(item.note)) + } + next() + } + + router.get('/:user', publicRoute(), redirectIfSingleUserMode, async (req, res) => { + try { + const wishlist = await wishlistManager.get(req.params.user) + const items = await wishlist.itemsVisibleToUser(req.user._id) + + for (const item of items) { + if (_CC.config.wishlist.note.markdown) item.note = DOMPurify.sanitize(marked(item.note)) } res.render('wishlist', { - title: _CC.lang('WISHLIST_TITLE', dbUser._id), - name: dbUser._id, - wishlist: [ - ...dbUser.wishlist.filter(item => item.addedBy === req.params.user), - ...dbUser.wishlist.filter(item => item.addedBy !== req.params.user) - ], - firstCanSee, - lastCanSee + title: _CC.lang('WISHLIST_TITLE', wishlist.username), + name: wishlist.username, + items }) } catch (error) { req.flash('error', error) @@ -82,216 +67,168 @@ module.exports = (db) => { }) router.post('/:user', verifyAuth(), async (req, res) => { - if (!req.body.itemUrlOrName) { - req.flash('error', _CC.lang('WISHLIST_URL_REQUIRED')) - return res.redirect(`/wishlist/${req.params.user}`) - } - const potentialUrl = req.body.itemUrlOrName.split(' ').pop() - const url = ValidURL(potentialUrl) - const item = {} - let productData try { - if (url) productData = await getProductName(url, config.proxyServer) - } catch (err) { - req.flash('error', err.toString()) - } - item.name = (productData ? productData.name : '') - item.price = productData?.price - item.image = productData?.image - item.addedBy = req.user._id - item.pledgedBy = (req.user._id === req.params.user || req.body.suggest ? undefined : req.user._id) - item.note = req.body.note - if (url) item.url = url - if (!url) item.name = req.body.itemUrlOrName - item.id = u64.encode(new Date().getTime().toString()) - const doc = await db.get(req.params.user) - doc.wishlist.push(item) - try { - await db.put(doc) - } catch { - req.flash('error', _CC.lang('WISHLIST_CONFLICT')) - return res.redirect(`/wishlist/${req.params.user}`) - } - req.flash( - 'success', - ( + const wishlist = await wishlistManager.get(req.params.user) + + const { nonFatalErrors } = await wishlist.add({ + itemUrlOrName: req.body.itemUrlOrName, + suggest: req.body.suggest, + note: req.body.note, + addedBy: req.user._id + }) + + for (const error of nonFatalErrors) { + req.flash('error', error) + } + + req.flash('success', req.user._id === req.params.user - ? 'Added item to wishlist' - : `Pleged item for ${req.params.user}` + ? _CC.lang('WISHLIST_ADDED_ITEM_TO_OWN_WISHLIST') + : _CC.lang('WISHLIST_PLEDGED_ITEM_FOR_USER', req.params.user) ) - ) + } catch (error) { + req.flash('error', `${error}`) + } + res.redirect(`/wishlist/${req.params.user}`) }) router.post('/:user/pledge/:itemId', verifyAuth(), async (req, res) => { - const docs = await db.allDocs({ include_docs: true }) - for (let i = 0; i < docs.rows.length; i++) { - for (let j = 0; j < docs.rows[i].doc.wishlist.length; j++) { - if (docs.rows[i].doc.wishlist[j].id === req.params.itemId) { - if (docs.rows[i].doc.wishlist[j].pledgedBy !== undefined) { - req.flash('error', _CC.lang('WISHLIST_PLEDGE_DUPLICATE')) - return res.redirect(`/wishlist/${req.params.user}`) - } - docs.rows[i].doc.wishlist[j].pledgedBy = req.user._id - await db.put(docs.rows[i].doc) - req.flash('success', _CC.lang('WISHLIST_PLEDGE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) - } + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = await wishlist.get(req.params.itemId) + + if (item.pledgedBy !== undefined) { + throw new Error(_CC.lang('WISHLIST_PLEDGE_DUPLICATE')) } + + await wishlist.pledge(item.id, req.user._id) + } catch (error) { + req.flash('error', `${error}`) } + + res.redirect(`/wishlist/${req.params.user}`) }) + router.post('/:user/unpledge/:itemId', verifyAuth(), async (req, res) => { - const docs = await db.allDocs({ include_docs: true }) - for (let i = 0; i < docs.rows.length; i++) { - for (let j = 0; j < docs.rows[i].doc.wishlist.length; j++) { - if (docs.rows[i].doc.wishlist[j].id === req.params.itemId) { - if (docs.rows[i].doc.wishlist[j].pledgedBy !== req.user._id) { - req.flash('error', _CC.lang('WISHLIST_UNPLEDGE_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) - } - docs.rows[i].doc.wishlist[j].pledgedBy = undefined - await db.put(docs.rows[i].doc) - req.flash('success', _CC.lang('WISHLIST_UNPLEDGE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) - } + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = await wishlist.get(req.params.itemId) + + const pledgedByUser = item.pledgedBy === req.user._id + if (!pledgedByUser) { + throw new Error(_CC.lang('WISHLIST_UNPLEDGE_GUARD')) } + + await wishlist.unpledge(item.id) + + req.flash('success', _CC.lang('WISHLIST_UNPLEDGE_SUCCESS')) + } catch (error) { + req.flash('error', `${error}`) } - req.flash('error', _CC.lang('WISHLIST_UNPLEDGE_MISSING')) - return res.redirect(`/wishlist/${req.params.user}`) + + res.redirect(`/wishlist/${req.params.user}`) }) router.post('/:user/remove/:itemId', verifyAuth(), async (req, res) => { - const doc = await db.get(req.params.user) - for (let i = 0; i < doc.wishlist.length; i++) { - if (doc.wishlist[i].id === req.params.itemId) { - if (req.user._id !== req.params.user && doc.wishlist[i].addedBy !== req.user._id) { - req.flash('error', _CC.lang('WISHLIST_REMOVE_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) - } + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = wishlist.get(req.params.itemId) - doc.wishlist.splice(i, 1) - await db.put(doc) - req.flash('success', _CC.lang('WISHLIST_REMOVE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) + const isOwnWishlist = req.user._id === wishlist.username + const addedByUser = item.addedBy === req.user._id + if (!isOwnWishlist && !addedByUser) { + throw new Error(_CC.lang('WISHLIST_REMOVE_GUARD')) } + + await wishlist.remove(item.id) + + req.flash('success', _CC.lang('WISHLIST_REMOVE_SUCCESS')) + } catch (error) { + req.flash('error', `${error}`) } - req.flash('error', _CC.lang('WISHLIST_REMOVE_MISSING')) - return res.redirect(`/wishlist/${req.params.user}`) + + res.redirect(`/wishlist/${req.params.user}`) }) router.post('/:user/move/:direction/:itemId', verifyAuth(), async (req, res) => { - if (req.user._id !== req.params.user) { - req.flash('error', _CC.lang('WISHLIST_MOVE_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) - } - const doc = await db.get(req.user._id) - let wishlist = doc.wishlist - if (req.params.direction === 'top') { - const item = wishlist.find(item => item.id === req.params.itemId) - wishlist = wishlist.filter(item => item.id !== req.params.itemId) - wishlist.unshift(item) - } else { - if (req.params.direction === 'up') wishlist.reverse() - let moveFromIndex - wishlist.forEach(wish => { - if (wish.id === req.params.itemId) moveFromIndex = wishlist.indexOf(wish) - }) - const moveToIndex = wishlist.findIndex(wish => (wishlist.indexOf(wish) > moveFromIndex && wish.addedBy === req.user._id)) - if (moveToIndex < 0 || moveToIndex > wishlist.length) { - req.flash('error', _CC.lang('WISHLIST_MOVE_INVALID')) - return res.redirect(`/wishlist/${req.params.user}`) + try { + if (req.user._id !== req.params.user) { + throw new Error(_CC.lang('WISHLIST_MOVE_GUARD')) } - [wishlist[moveFromIndex], wishlist[moveToIndex]] = [wishlist[moveToIndex], wishlist[moveFromIndex]] - if (req.params.direction === 'up') wishlist.reverse() + + const wishlist = await wishlistManager.get(req.params.user) + + if (req.params.direction === 'top') { + await wishlist.moveTop(req.params.itemId) + } else if (req.params.direction === 'up') { + await wishlist.move(req.params.itemId, -1) + } else if (req.params.direction === 'down') { + await wishlist.move(req.params.itemId, 1) + } else { + throw new Error(_CC.lang('WISHLIST_MOVE_UNKNOWN_DIRECTION')) + } + + req.flash('success', _CC.lang('WISHLIST_MOVE_SUCCESS')) + } catch (error) { + req.flash('error', `${error}`) } - doc.wishlist = wishlist - await db.put(doc) - req.flash('success', _CC.lang('WISHLIST_MOVE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) + res.redirect(`/wishlist/${req.params.user}`) }) router.get('/:user/note/:id', verifyAuth(), async (req, res) => { - const doc = await db.get(req.params.user) - const item = doc.wishlist.find(item => item.id === req.params.id) - res.render('note', { item }) + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = await wishlist.get(req.params.id) + res.render('note', { item }) + } catch (error) { + req.flash('error', `${error}`) + res.redirect(`/wishlist/${req.params.user}`) + } }) + router.post('/:user/note/:id', verifyAuth(), async (req, res) => { - const doc = await db.get(req.params.user) - const wishlist = doc.wishlist - for (let i = 0; i < wishlist.length; i++) { - const wishlistItem = wishlist[i] - if (wishlistItem.id !== req.params.id) continue - if (req.user._id !== req.params.user && req.user._id !== wishlistItem.addedBy) { - req.flash('error', _CC.lang('NOTE_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = await wishlist.get(req.params.id) + + const isOwnWishlist = req.user._id === req.params.user + const addedByUser = req.user._id === item.addedBy + if (!isOwnWishlist && !addedByUser) { + throw new Error(_CC.lang('NOTE_GUARD')) } - for (const type of [ - 'name', 'note', 'url', 'price', 'image' - ]) { - if (!Object.prototype.hasOwnProperty.call(req.body, type)) { - req.flash('error', _CC.lang('NOTE_MISSING_PROP', type)) - return res.redirect(`/wishlist/${req.params.user}/note/${req.params.id}`) - } - wishlistItem[type] = req.body[type] - } - wishlist[i] = wishlistItem + + await wishlist.setItemData(req.params.id, req.body) + + req.flash('success', _CC.lang('NOTE_SUCCESS')) + res.redirect(`/wishlist/${req.params.user}`) + } catch (error) { + req.flash('error', `${error}`) + res.redirect(`/wishlist/${req.params.user}/note/${req.params.id}`) } - doc.wishlist = wishlist - await db.put(doc) - req.flash('success', _CC.lang('NOTE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) }) + router.post('/:user/refresh/:id', verifyAuth(), async (req, res) => { - const doc = await db.get(req.params.user) - const wishlist = doc.wishlist - for (let i = 0; i < wishlist.length; i++) { - const wishlistItem = wishlist[i] - if (wishlistItem.id !== req.params.id) continue - if (req.user._id !== req.params.user && req.user._id !== wishlistItem.addedBy) { - req.flash('error', _CC.lang('WISHLIST_REFRESH_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) + try { + const wishlist = await wishlistManager.get(req.params.user) + const item = await wishlist.get(req.params.id) + + const isOwnWishlist = req.user._id === req.params.user + const addedByUser = req.user._id === item.addedBy + if (!isOwnWishlist && !addedByUser) { + throw new Error(_CC.lang('WISHLIST_REFRESH_GUARD')) } - if (!wishlistItem.url) { - req.flash('error', _CC.lang('WISHLIST_REFRESH_NO_URL')) - return res.redirect(`/wishlist/${req.params.user}/note/${req.params.id}`) - } + await wishlist.refreshItemData(item.id) - const productData = await getProductName(wishlistItem.url) - for (const field of ['name', 'price', 'image']) { - if (productData[field]) wishlistItem[field] = productData[field] - } - - wishlist[i] = wishlistItem + req.flash('success', _CC.lang('WISHLIST_REFRESH_SUCCESS')) + } catch (error) { + req.flash('error', `${error}`) } - doc.wishlist = wishlist - await db.put(doc) - req.flash('success', _CC.lang('WISHLIST_REFRESH_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}/note/${req.params.id}`) - }) - router.post('/:user/note/remove/:id', verifyAuth(), async (req, res) => { - const doc = await db.get(req.params.user) - const wishlist = doc.wishlist - for (let i = 0; i < wishlist.length; i++) { - const wishlistItem = wishlist[i] - if (wishlistItem.id !== req.params.id) continue - if (req.user._id !== req.params.user && req.user._id !== wishlistItem.addedBy) { - req.flash('error', _CC.lang('NOTE_REMOVE_GUARD')) - return res.redirect(`/wishlist/${req.params.user}`) - } - if (wishlistItem.note) { - wishlistItem.note = undefined - wishlist[i] = wishlistItem - } else { - req.flash('error', _CC.lang('NOTE_REMOVE_MISSING')) - return res.redirect(`/wishlist/${req.params.user}`) - } - } - doc.wishlist = wishlist - await db.put(doc) - req.flash('success', _CC.lang('NOTE_REMOVE_SUCCESS')) - return res.redirect(`/wishlist/${req.params.user}`) + + res.redirect(`/wishlist/${req.params.user}/note/${req.params.id}`) }) + return router } diff --git a/src/structures/Wishlist.js b/src/structures/Wishlist.js new file mode 100644 index 0000000..927b0b4 --- /dev/null +++ b/src/structures/Wishlist.js @@ -0,0 +1,182 @@ +const getProductData = require('get-product-name') +const u64 = require('u64') + +class Wishlist { + static async new (username) { + const instance = new this({ username }) + await instance.fetch() + return instance + } + + constructor (opts) { + this.username = opts.username + } + + async fetch () { + try { + this.doc = await _CC.usersDb.get(this.username) + } catch { + throw new Error(_CC.lang('WISHLIST_FETCH_FAIL')) + } + this.items = this.doc.wishlist + } + + async save () { + try { + const { rev } = await _CC.usersDb.put(this.doc) + this.doc._rev = rev + } catch { + await this.fetch() + throw new Error(_CC.lang('WISHLIST_CONFLICT')) + } + } + + async get (id) { + const item = this.items.find(item => item.id === id) + if (!item) throw new Error(_CC.lang('WISHLIST_ITEM_MISSING')) + return item + } + + async itemsVisibleToUser (username) { + const addedBySelfAtTop = async (items) => { + return [ + ...items.filter(item => item.addedBy === this.username), + ...items.filter(item => item.addedBy !== this.username) + ] + } + + if (this.username === username) { + return this.items + .filter(item => item.addedBy === username) + } + + return addedBySelfAtTop(this.items) + } + + async add ({ itemUrlOrName, suggest, note, addedBy }) { + if (!itemUrlOrName) { + throw new Error(_CC.lang('WISHLIST_URL_REQUIRED')) + } + + const item = {} + + const nonFatalErrors = [] + + const potentialUrl = itemUrlOrName.split(' ').pop() + const url = parseURL(potentialUrl) + let productData + try { + if (url) productData = await getProductData(url, _CC.config.proxyServer) + } catch (err) { + nonFatalErrors.push(err.toString()) + } + + item.id = u64.encode(new Date().getTime().toString()) + item.name = (productData ? productData.name : '') + item.price = productData?.price + item.image = productData?.image + item.addedBy = addedBy + item.pledgedBy = (addedBy === this.username || suggest ? undefined : addedBy) + item.note = note + + if (url) item.url = url + if (!url) item.name = itemUrlOrName + + this.items.push(item) + await this.save() + + return { nonFatalErrors } + } + + async remove (id) { + const index = this.items.findIndex(item => item.id === id) + if (index === -1) throw new Error(_CC.lang('WISHLIST_ITEM_MISSING')) + this.items.splice(index, 1) + await this.save() + } + + async pledge (id, user) { + const item = await this.get(id) + item.pledgedBy = user + await this.save() + } + + async unpledge (id) { + const item = await this.get(id) + item.pledgedBy = undefined + await this.save() + } + + async move (id, places) { + if (places === 0) throw new Error('places should never be 0') + + const index = this.items.findIndex(item => item.id === id) + if (index === -1) throw new Error(_CC.lang('WISHLIST_ITEM_MISSING')) + + while (this.items[index + places] && this.items[index + places].addedBy !== this.username) { + if (places < 0) { + places-- + } else { + places++ + } + } + if (index < 0 || index >= this.items.length || index + places < 0 || index + places >= this.items.length) { + throw new Error(_CC.lang('WISHLIST_MOVE_INVALID')) + } + + const item = this.items.splice(index, 1)[0] + this.items.splice(index + places, 0, item) + await this.save() + } + + async moveTop (id) { + const index = this.items.findIndex(item => item.id === id) + if (index === -1) throw new Error(_CC.lang('WISHLIST_ITEM_MISSING')) + + const item = this.items.splice(index, 1)[0] + this.items.unshift(item) + await this.save() + } + + async setItemData (id, data) { + const item = await this.get(id) + + for (const key of [ + 'name', 'note', 'url', 'price', 'image' + ]) { + if (!Object.prototype.hasOwnProperty.call(data, key)) { + throw new Error(_CC.lang('NOTE_MISSING_PROP', key)) + } + item[key] = data[key] + } + + await this.save() + } + + async refreshItemData (id) { + const item = await this.get(id) + + if (!item.url) { + throw new Error(_CC.lang('WISHLIST_REFRESH_NO_URL')) + } + + const productData = await getProductData(item.url) + for (const key of ['name', 'price', 'image']) { + if (productData[key]) item[key] = productData[key] + } + + await this.save() + } +} + +function parseURL (string) { + try { + const url = new URL(string) + if (_CC.config.wishlist.smile) { + if (url.hostname === 'www.amazon.com') url.hostname = 'smile.amazon.com' + } + if (url) return url + } catch {} +} + +module.exports = { Wishlist } diff --git a/src/structures/WishlistManager.js b/src/structures/WishlistManager.js new file mode 100644 index 0000000..cce6dd1 --- /dev/null +++ b/src/structures/WishlistManager.js @@ -0,0 +1,22 @@ +const { Wishlist } = require('./Wishlist') + +class WishlistManager { + constructor () { + this.wishlistsCache = new Map() + } + + async get (username) { + const cached = this.wishlistsCache.get(username) + if (cached) return cached + + const wishlist = await Wishlist.new(username) + this.wishlistsCache.set(username, wishlist) + return wishlist + } + + async clearCache () { + this.wishlistsCache = new Map() + } +} + +module.exports = { WishlistManager } diff --git a/src/views/wishlist.pug b/src/views/wishlist.pug index 67673d4..039d7dd 100644 --- a/src/views/wishlist.pug +++ b/src/views/wishlist.pug @@ -32,108 +32,107 @@ block content th= lang('WISHLIST_PLEDGE') th= lang('WISHLIST_DELETE') tbody - each item, index in wishlist - if req.user._id === item.addedBy || req.params.user !== req.user._id - tr(id=item.id) - td.rank= index + 1 - td - figure(style='width: 100%; margin: 0;') - img(src=item.image, style='width: 100%; max-height: 20em; object-fit: contain;') - if item.url - td.ugc(data-label='Name') - a( - href=item.url, - rel='noopener noreferrer', - target='_blank' - )= (item.name ? item.name : item.url) - else - td.ugc(data-label=lang('WISHLIST_NAME'))= item.name - if _CC.config.wishlist.note.markdown - td.ugc(data-label=lang('WISHLIST_NOTE')) - div!= item.note - else - td.ugc(data-label=lang('WISHLIST_NOTE'))= item.note - td.ugc(data-label=lang('WISHLIST_PRICE'))= item.price - td(data-label=lang('WISHLIST_EDIT_ITEM')) - form.inline(method='GET', action=`${_CC.config.base}wishlist/${req.params.user}/note/${item.id}`) - .field.inline - .control.inline - button.button.is-text( - type='submit', - style='text-decoration: none;' - disabled=item.addedBy !== req.user._id - ) - span.icon - i.far.fa-edit - td.ugc(data-label=lang('WISHLIST_ADDED_BY'))= item.addedBy - if req.params.user === req.user._id - td(data-label=lang('WISHLIST_MOVE_ITEM_TOP')) - form.topForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/top/${item.id}`) - .field.inline - .control.inline - button.button.is-text( - type='submit', - style='text-decoration: none;', - disabled=index === firstCanSee - ) - span.icon - i.fas.fa-angle-double-up - td(data-label=lang('WISHLIST_MOVE_ITEM_UP')) - form.upForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/up/${item.id}`) - .field.inline - .control.inline - button.button.is-text( - type='submit', - style='text-decoration: none;', - disabled=index === firstCanSee - ) - span.icon - i.fas.fa-arrow-up - td(data-label=lang('WISHLIST_MOVE_ITEM_DOWN')) - form.downForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/down/${item.id}`) - .field.inline - .control.inline - button.button.is-text( - type='submit', - style='text-decoration: none;', - disabled=index === lastCanSee - ) - span.icon - i.fas.fa-arrow-down - else - td(data-label=lang('WISHLIST_PLEDGE')) - if req.params.user !== req.user._id && !item.pledgedBy - form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/pledge/${item.id}`) - .field.inline - .control.inline - input.inline.button.is-primary(type='submit' value=lang('WISHLIST_PLEDGE_ITEM')) - if item.pledgedBy === req.user._id - form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/unpledge/${item.id}`) - .field.inline - .control.inline - input.inline.button(type='submit' value=lang('WISHLIST_UNPLEDGE')) - if item.pledgedBy && item.pledgedBy !== req.user._id - if item.pledgedBy === '_CCUNKNOWN' - span.ugc=lang('WISHLIST_PLEDGED_GUEST') - else - span.ugc=lang('WISHLIST_PLEDGED', item.pledgedBy) - td(data-label='Delete Item') - form.inline( - method='POST', - action=`${_CC.config.base}wishlist/${req.params.user}/remove/${item.id}` - ) + each item, index in items + tr(id=item.id) + td.rank= index + 1 + td + figure(style='width: 100%; margin: 0;') + img(src=item.image, style='width: 100%; max-height: 20em; object-fit: contain;') + if item.url + td.ugc(data-label='Name') + a( + href=item.url, + rel='noopener noreferrer', + target='_blank' + )= (item.name ? item.name : item.url) + else + td.ugc(data-label=lang('WISHLIST_NAME'))= item.name + if _CC.config.wishlist.note.markdown + td.ugc(data-label=lang('WISHLIST_NOTE')) + div!= item.note + else + td.ugc(data-label=lang('WISHLIST_NOTE'))= item.note + td.ugc(data-label=lang('WISHLIST_PRICE'))= item.price + td(data-label=lang('WISHLIST_EDIT_ITEM')) + form.inline(method='GET', action=`${_CC.config.base}wishlist/${req.params.user}/note/${item.id}`) + .field.inline + .control.inline + button.button.is-text( + type='submit', + style='text-decoration: none;' + disabled=item.addedBy !== req.user._id + ) + span.icon + i.far.fa-edit + td.ugc(data-label=lang('WISHLIST_ADDED_BY'))= item.addedBy + if req.params.user === req.user._id + td(data-label=lang('WISHLIST_MOVE_ITEM_TOP')) + form.topForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/top/${item.id}`) .field.inline .control.inline button.button.is-text( type='submit', style='text-decoration: none;', - disabled=item.addedBy !== req.user._id + disabled=index === 0 ) span.icon - i.fas.fa-trash + i.fas.fa-angle-double-up + td(data-label=lang('WISHLIST_MOVE_ITEM_UP')) + form.upForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/up/${item.id}`) + .field.inline + .control.inline + button.button.is-text( + type='submit', + style='text-decoration: none;', + disabled=index === 0 + ) + span.icon + i.fas.fa-arrow-up + td(data-label=lang('WISHLIST_MOVE_ITEM_DOWN')) + form.downForm.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/down/${item.id}`) + .field.inline + .control.inline + button.button.is-text( + type='submit', + style='text-decoration: none;', + disabled=index === items.length - 1 + ) + span.icon + i.fas.fa-arrow-down + else + td(data-label=lang('WISHLIST_PLEDGE')) + if req.params.user !== req.user._id && !item.pledgedBy + form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/pledge/${item.id}`) + .field.inline + .control.inline + input.inline.button.is-primary(type='submit' value=lang('WISHLIST_PLEDGE_ITEM')) + if item.pledgedBy === req.user._id + form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/unpledge/${item.id}`) + .field.inline + .control.inline + input.inline.button(type='submit' value=lang('WISHLIST_UNPLEDGE')) + if item.pledgedBy && item.pledgedBy !== req.user._id + if item.pledgedBy === '_CCUNKNOWN' + span.ugc=lang('WISHLIST_PLEDGED_GUEST') + else + span.ugc=lang('WISHLIST_PLEDGED', item.pledgedBy) + td(data-label='Delete Item') + form.inline( + method='POST', + action=`${_CC.config.base}wishlist/${req.params.user}/remove/${item.id}` + ) + .field.inline + .control.inline + button.button.is-text( + type='submit', + style='text-decoration: none;', + disabled=item.addedBy !== req.user._id + ) + span.icon + i.fas.fa-trash else - each item, index in wishlist + each item, index in items if req.user._id === item.addedBy || req.params.user !== req.user._id .box if item.price @@ -187,12 +186,12 @@ block content .field.inline .control.inline input.inline.button(type='submit', value=lang('WISHLIST_EDIT_ITEM')) - if index !== firstCanSee && req.user._id === req.params.user + if index !== 0 && req.user._id === req.params.user form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/up/${item.id}`) .field.inline .control.inline input.inline.button(type='submit' value=lang('WISHLIST_MOVE_ITEM_UP')) - if index !== lastCanSee && req.user._id === req.params.user + if index !== items.length - 1 && req.user._id === req.params.user form.inline(method='POST', action=`${_CC.config.base}wishlist/${req.params.user}/move/down/${item.id}`) .field.inline .control.inline @@ -226,7 +225,7 @@ block content block print h1 #{req.params.user}'s Wishlist .print-gifts - each item, index in wishlist.filter(item => item.addedBy === req.params.user) + each item, index in items.filter(item => item.addedBy === req.params.user) .print-gift(style='page-break-inside: avoid;') if index > 0 hr(style='margin-top: .5em; margin-bottom: .5em; background-color: black;')