diff --git a/package-lock.json b/package-lock.json index f47a0eb..45310fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", "express": "^4.17.1", + "express-ws": "^4.0.0", "node-fetch": "^2.6.1", "sharp": "^0.27.0", "yargs": "^16.2.0" @@ -86,6 +87,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -629,6 +635,20 @@ "node": ">= 0.10.0" } }, + "node_modules/express-ws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-4.0.0.tgz", + "integrity": "sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw==", + "dependencies": { + "ws": "^5.2.0" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, "node_modules/express/node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1522,6 +1542,14 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, + "node_modules/ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, "node_modules/y18n": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", @@ -1658,6 +1686,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2080,6 +2113,14 @@ } } }, + "express-ws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-4.0.0.tgz", + "integrity": "sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw==", + "requires": { + "ws": "^5.2.0" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2760,6 +2801,14 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } + }, "y18n": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", diff --git a/package.json b/package.json index 7d07d18..ffc367f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", "express": "^4.17.1", + "express-ws": "^4.0.0", "node-fetch": "^2.6.1", "sharp": "^0.27.0", "yargs": "^16.2.0" diff --git a/server.js b/server.js index 76380b4..6b393f4 100644 --- a/server.js +++ b/server.js @@ -86,12 +86,12 @@ const COOKIE_KEY = Buffer.from(db.prepare("SELECT value FROM properties WHERE ke // express config const app = express(); -app.use(express.static('public')); +const expressWs = require('express-ws')(app); +app.use(express.static('public')); app.use(bodyParser.raw({type: 'image/jpeg', limit: '25mb'})); app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()); - app.set('json spaces', 2); app.use(cookieParser()); @@ -126,6 +126,20 @@ function decryptCookie(ciphertext){ return JSON.parse(deciphered); } +// broadcast isn't user specific right now... +app.ws('/ws/:uid', (ws, req) => { + ws.on("message", (msg) => { + //console.log("received messsage: " + msg); + }); + console.log("socket opened for user " + req.params.uid); +}); + +function broadcast(uid, msg){ + for ( let socket of expressWs.getWss('/ws/' + uid).clients ){ + socket.send(JSON.stringify(msg)); + } +} + // handle auth app.use ( async (req, res, next) => { @@ -283,7 +297,7 @@ const SERVER_ERROR = {status: "error", error: "server error"}; app.get("/api/whoami", (req, res) => { - res.send({name: req.user.name, version: VERSION}); + res.send({name: req.user.name, version: VERSION, id: req.user.id}); }); // list boards @@ -335,7 +349,8 @@ app.post('/api/boards', (req, res) => { board.titlePinId = 0; res.send(board); console.log(`Created board#${id} ${req.body.name}`); - + + broadcast(req.user.id, {b:id}); } catch (err){ console.log("Error creating board: " + err.message); if ( err.message.includes('UNIQUE constraint failed:') ){ @@ -383,6 +398,7 @@ app.delete("/api/boards/:boardId", async (req, res) => { if ( result.changes == 1 ){ res.send(OK); + broadcast(req.user.id, {deleteBoard: req.params.boardId}); } else { res.status(404).send(NOT_FOUND); } @@ -411,6 +427,18 @@ app.get("/api/pins/:pinId", (req, res) => { app.post("/api/pins", async (req, res) => { try { + let boardId = req.body.boardId; + + if ( boardId == "new" ){ + try { + let result = db.prepare("INSERT INTO boards (name, userId, hidden, createDate) VALUES (@name, @userId, @hidden, @createDate)").run({name: req.body.newBoardName, userId: req.user.id, hidden: 0, createDate: new Date().toISOString()}); + boardId = result.lastInsertRowid; + } catch (e){ + console.log("error creating new board: ", err); + res.status(500).send(SERVER_ERROR); + } + } + // download the image first to make sure we can get it let image = await downloadImage(req.body.imageUrl); @@ -439,7 +467,7 @@ app.post("/api/pins", async (req, res) => { @userId, @createDate) `).run({ - boardId: req.body.boardId, + boardId: boardId, imageUrl: req.body.imageUrl, siteUrl: req.body.siteUrl, description: req.body.description, @@ -460,12 +488,15 @@ app.post("/api/pins", async (req, res) => { let pin = db.prepare("SELECT * FROM pins WHERE userId = @userId and id = @pinId").get({userId: req.user.id, pinId: id}); res.send(pin); + broadcast(req.user.id, {updateBoard:boardId}); + } catch (err) { console.log(`Error creating pin: ${err.message}`, err); res.status(500).send(SERVER_ERROR); } }); +// create pin app.post("/api/pins/:pinId", (req,res) => { try { @@ -487,6 +518,7 @@ app.post("/api/pins/:pinId", (req,res) => { if ( result.changes == 1 ){ console.log(`updated pin#${req.params.pinId}`) res.send(OK); + broadcast(req.user.id, {updateBoard:req.body.boardId}); } else { res.status(404).send(NOT_FOUND); } @@ -497,9 +529,12 @@ app.post("/api/pins/:pinId", (req,res) => { }); +// delete pin app.delete("/api/pins/:pinId", async (req, res) => { try { + let pin = db.prepare('SELECT * FROM pins WHERE userId = @userId and id = @pinId').get({userId: req.user.id, pinId:req.params.pinId}); + let result = db.prepare('DELETE FROM pins WHERE userId = @userId and id = @pinId').run({userId: req.user.id, pinId:req.params.pinId}); if ( result.changes == 1 ){ @@ -512,6 +547,9 @@ app.delete("/api/pins/:pinId", async (req, res) => { console.log(`deleted pin#${req.params.pinId}`); res.send(OK); + + broadcast(req.user.id, {updateBoard:pin.boardId}); + } else { res.status(404).send(NOT_FOUND); } @@ -592,6 +630,10 @@ app.post("/up/", async (req, res) => { return; }); +app.get("/otl/:l", (req, res) => { + +}); + // start listening diff --git a/static/app.js b/static/app.js index 20a7ebc..ec2e2b3 100644 --- a/static/app.js +++ b/static/app.js @@ -25,13 +25,56 @@ app.addSetter("load.boards", async (data) => { store.do("loader.hide"); }); -app.addSetter('load.board', async (data) => { +// handle update events +window.addEventListener("broadcast", async (e) => { + + let data = store.data; + + if ( e.detail.updateBoard ){ + console.log("updating board"); + let boardId = e.detail.updateBoard; + + let boardExists = false; + for ( let i = 0; i < data.boards.length; ++i ){ + if ( data.boards[i].id == boardId ){ + boardExists = true; + } + } + + // if it's a new board + if ( !boardExists ){ + store.do("load.boards"); + } + + // if we are currently viewing this board, reload the pins + if ( data.board && boardId == data.board.id ){ + store.do("load.board", true); + } + } else if ( e.detail.deleteBoard ) { + console.log("deleting board"); + let boardId = e.detail.deleteBoard; + + // reload the boards + store.do("load.boards"); + + // we're currently looking at this board... alert and error + if ( data.board && boardId == data.board.id ){ + window.alert("this board has been deleted on another device"); + window.location.hash = "#"; + } + } + +}); + +app.addSetter('load.board', async (data, force) => { store.do("loader.show"); - if ( !data.board || data.board.id != data.hash.board ){ - let res = await fetch("/api/boards/" + data.hash.board); - data.board = await res.json(); - } + if ( data.hash.board ){ + if ( force || !data.board || data.board.id != data.hash.board ){ + let res = await fetch("/api/boards/" + data.hash.board); + data.board = await res.json(); + } + } store.do("loader.hide"); }); @@ -42,6 +85,9 @@ app.addSetter('load.user', async (data) => { let res = await fetch("/api/whoami"); data.user = await res.json(); + window.uid = data.user.id; + window.socketConnect(); + store.do("loader.hide"); }); @@ -225,4 +271,12 @@ store.do('load.user'); store.do('load.boards'); store.do('hash.update'); -appComponent.render(); \ No newline at end of file +appComponent.render(); + + +// refresh on focus +window.addEventListener("focus", () => { + store.do("load.boards"); + store.do("load.board"); + window.dispatchEvent(new CustomEvent("socket-connect")); +}); \ No newline at end of file diff --git a/static/client.css b/static/client.css index 1552554..4fdb2ae 100644 --- a/static/client.css +++ b/static/client.css @@ -286,6 +286,21 @@ margin-top: -2px; } +/* https://thenounproject.com/search/?q=connected&i=88200 */ +#socketConnected { + background-image: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMWExYTFhIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMTAwIDEwMCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHBhdGggZD0iTTkwLjMsNDguMUM5MC41LDYxLjksNzkuNCw3My41LDY1LjYsNzRjLTMuNywwLjEtNy4yLTAuNi0xMC40LTEuOWMtMS42LTAuNy0yLjMtMi42LTEuNS00LjJsMC0wLjFjMC43LTEuMywyLjMtMS45LDMuNi0xLjMgIGMzLDEuMyw2LjQsMS44LDEwLDEuM2M4LjktMS4yLDE1LjktOC41LDE2LjgtMTcuNGMxLjItMTIuMi05LTIyLjUtMjEuMy0yMS4zQzU0LDI5LjksNDYuNiwzNyw0NS41LDQ2Yy0wLjMsMi41LTAuMiw0LjksMC40LDcuMSAgYzAuMywxLjEtMC4xLDIuMi0wLjksMi45YzAsMCwwLDAsMCwwYy0xLjcsMS41LTQuNCwwLjctNC45LTEuNWMtMC44LTMuMy0xLTYuOS0wLjMtMTAuNmMyLTEwLjksMTEtMTkuNCwyMi0yMC43ICBDNzcsMjEuNCw5MC4xLDMzLjIsOTAuMyw0OC4xeiBNMzYuOSw3NC44YzExLTEuMywyMC05LjgsMjItMjAuN2MwLjctMy43LDAuNS03LjMtMC4zLTEwLjdjLTAuNS0yLjItMy4yLTMuMS00LjktMS41bDAsMCAgYy0wLjgsMC43LTEuMSwxLjgtMC45LDIuOWMwLjYsMi4zLDAuNyw0LjcsMC40LDcuMmMtMS4yLDguOS04LjUsMTYtMTcuNSwxNi45QzIzLjQsNzAsMTMuMiw1OS44LDE0LjQsNDcuNiAgYzAuOS04LjksNy45LTE2LjIsMTYuOC0xNy40YzMuNi0wLjUsNywwLjEsMTAsMS4zYzEuNCwwLjYsMi45LDAsMy42LTEuM2wwLTAuMWMwLjgtMS42LDAuMi0zLjUtMS41LTQuMmMtMy43LTEuNS03LjktMi4yLTEyLjMtMS43ICBDMTksMjUuNCw5LjQsMzUuMyw4LjQsNDcuNEM3LjEsNjMuNCwyMC43LDc2LjcsMzYuOSw3NC44eiI+PC9wYXRoPjwvc3ZnPg=="); + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: 6px 8px; + opacity: 0.1; + margin-left: 10px; +} + +body.socketConnected #socketConnected { + opacity: 1; +} + + .modal-card { max-width: 95%; } diff --git a/static/components/addpin.js b/static/components/addpin.js index fd16e92..dd83ed9 100644 --- a/static/components/addpin.js +++ b/static/components/addpin.js @@ -48,26 +48,9 @@ app.addSetter('addPinModal.save', async (data) => { let boardId = data.addPinModal.boardId; - let newBoard = null; - - if ( boardId == "new" ){ - let res = await fetch('api/boards', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - "name": data.addPinModal.newBoardName - }) - }); - - if ( res.status == 200 ){ - newBoard = await res.json(); - boardId = newBoard.id; - data.boards.push(newBoard); - } - } - let postData = { boardId: boardId, + newBoardName: data.addPinModal.newBoardName, imageUrl: data.addPinModal.imageUrl, siteUrl: data.addPinModal.siteUrl, description: data.addPinModal.description @@ -80,20 +63,21 @@ app.addSetter('addPinModal.save', async (data) => { }, body: JSON.stringify(postData) }); - + if ( res.status == 200 ){ let body = await res.json(); if ( data.board && data.board.id == boardId ){ data.board.pins.push(body); } - - if ( newBoard ){ - newBoard.titlePinId = body.id; - } window.localStorage.addPinLastBoardId = boardId; store.do("addPinModal.close"); + + // if we don't have a listening socket, we need to trigger our own update + if ( boardId == "new" && !window.socketConnected ){ + store.do("load.boards"); + } } store.do("loader.hide"); diff --git a/static/components/navbar.js b/static/components/navbar.js index d83df3f..63b8536 100644 --- a/static/components/navbar.js +++ b/static/components/navbar.js @@ -76,7 +76,9 @@ app.addComponent('navbar', (store) => { return new Reef("#navbar", { ${boardName}
+
+