From 08f500f67d6a818c20d48930991ed0b050e89aa7 Mon Sep 17 00:00:00 2001 From: brian Date: Thu, 8 Apr 2021 08:24:47 -0400 Subject: [PATCH] user bin listing --- nginx/public_html/App.js | 34 ++++++---- nginx/public_html/api.js | 7 ++ nginx/public_html/handlers/App.js | 9 ++- nginx/public_html/handlers/index.js | 14 ++-- nginx/public_html/index.js | 37 ++++++++--- nginx/public_html/models.js | 1 + node/server.js | 99 ++++++++++++++++++++++++----- 7 files changed, 152 insertions(+), 49 deletions(-) diff --git a/nginx/public_html/App.js b/nginx/public_html/App.js index 7f162a9..f6a60e8 100644 --- a/nginx/public_html/App.js +++ b/nginx/public_html/App.js @@ -16,12 +16,12 @@ import { function App(vnode_init){ - const {state, dispatch} = vnode_init.attrs; - const o = function(handler){ return handler.bind(null, state, dispatch); } + const {dispatch} = vnode_init.attrs; //load_notes(state, dispatch); return { view: function(vnode){ const s = vnode.attrs.state; + const o = function(handler){ return handler.bind(null, s, dispatch); } return m('.app', {key: 'app'}, [ m('.top', {key: 'top'}, [ m('.top-left', {key: 'top-left'}, [ @@ -33,16 +33,19 @@ function App(vnode_init){ ]) ]), m('.top-right', {key: 'top-right'}, [ - (s.login.is_logged_in - ? m('.login-info', {key:'login-info'}, [ - m('.text', 'Logged in as '+s.login.user.username), - m('button', {onclick: o(logout_request_handler)}, 'Logout') - ]) - : m('.login-form', {key:'login-form'}, [ - m('input.username', {key:'username', value: s.login.username, onchange: o(username_change_handler)}), - m('input.password[type=password]', {key:'password', value: s.login.password, onchange: o(password_change_handler)}), - m('button', {key:'login-button', onclick: o(login_request_handler)}, 'Login/Auto-Register') - ]) + (s.login.showing + ? (s.login.is_logged_in + ? m('.login-info', {key:'login-info'}, [ + m('.text', 'Logged in as '+s.login.user.username), + m('button', {onclick: o(logout_request_handler)}, 'Logout') + ]) + : m('.login-form', {key:'login-form'}, [ + m('input.username', {key:'username', value: s.login.username, onchange: o(username_change_handler)}), + m('input.password[type=password]', {key:'password', value: s.login.password, onchange: o(password_change_handler)}), + m('button', {key:'login-button', onclick: o(login_request_handler)}, 'Login/Auto-Register') + ]) + ) + : m('.empty', {key:'empty'}) ), m('.buttons', {key:'buttons'}, [ (s.is_editing_bin_name @@ -54,12 +57,15 @@ function App(vnode_init){ ? m('button', {key:'save-bin-name-button', onclick: o(bin_name_commit_handler)}, 'Save') : m('button', {key:'rename-bin-button', onclick: o(bin_name_editing_toggle_button_handler)}, 'Rename') ) - ]) + ]), + m('ul.bin-list', {key:'bin-list'}, s.login.bins.map(b=> + m('li.bin', b.name) + )) ]) ]), m('.main', {key: 'main'}, [ m('.notes', s.notes.map(note_state => - m(Note, {state, note_state, dispatch, key: note_state.note.id}) + m(Note, {state:s, note_state, dispatch, key: note_state.note.id}) )) ]) ]); diff --git a/nginx/public_html/api.js b/nginx/public_html/api.js index 7e68bac..259ebd4 100644 --- a/nginx/public_html/api.js +++ b/nginx/public_html/api.js @@ -5,8 +5,15 @@ const api = {}; // not `/api` (relative to domain root) nor `//api` (results in https:///api/...) let prefix = "api"; +let store = null; + +api.setStore = function(s){ + store=s; + }; api.post = function(url, body){ + body = body || {}; + body.session_id = store.getState().login.session_id; return m.request({ method: 'POST', url: prefix+url, diff --git a/nginx/public_html/handlers/App.js b/nginx/public_html/handlers/App.js index b9c3671..21e1dbd 100644 --- a/nginx/public_html/handlers/App.js +++ b/nginx/public_html/handlers/App.js @@ -32,7 +32,7 @@ const new_bin_handler = function(state, dispatch){ const id = nanoid(); // change browser location in address bar: window.history.pushState(null,'','#'+id); - dispatch('new-bin', {id}); + dispatch('new-bin', {id, name:id}); }; const username_change_handler = function(state, dispatch, e){ dispatch('update-username', e.target.value); @@ -45,7 +45,8 @@ const login_request_handler = function(state, dispatch, e){ api.post('/login', {username: state.login.username, password: state.login.password}) .then(res=>{ if(res.success===true){ - dispatch('login-succeeded', res.user, res.session_id); + dispatch('login-succeeded', {user:res.user, session_id:res.session_id}); + dispatch('user-bin-list-loaded', res.bins); } else{ dispatch('login-failed'); @@ -54,8 +55,9 @@ const login_request_handler = function(state, dispatch, e){ .catch(e=>{ dispatch('login-failed'); }); }; const logout_request_handler = function(state, dispatch, e){ + api.post('/logout', {}); + // we dispatch after the API call because the logout action clears the session_id, which is needed to logout dispatch('logout-requested'); - api.post('/logout', {session_id: state.login.session_id}); }; const bin_name_editing_toggle_button_handler = function(state, dispatch){ dispatch('update-bin-name-editing', !state.is_editing_bin_name); @@ -64,6 +66,7 @@ const bin_name_change_handler = function(state, dispatch, e){ dispatch('update-bin-name', e.target.value); }; const bin_name_commit_handler = function(state, dispatch){ + api.post('/bin-rename', {bin_id:state.bin.id, name:state.temp_bin_name}); dispatch('commit-bin-name'); }; diff --git a/nginx/public_html/handlers/index.js b/nginx/public_html/handlers/index.js index 4230b43..d001838 100644 --- a/nginx/public_html/handlers/index.js +++ b/nginx/public_html/handlers/index.js @@ -1,12 +1,10 @@ import api from '../api.js'; -const load_bin_handler = function(state, dispatch, bin_id){ - /* - api.post('/load-bin', {id: bin_id}) +const initial_load_bin_handler = function(state, dispatch, bin_id){ + api.post('/load-bin', {bin_id}) .then(res => { - dispatch('bin-loaded', {bin:res.bin, _notes:res.notes}); + dispatch('bin-loaded', res.bin); }); - */ api.post('/load-notes', {bin_id}) .then(res => { dispatch('notes-loaded', res.notes); @@ -22,6 +20,10 @@ const hash_change_handler = function(state, dispatch, e){ } else{ dispatch('bin-requested', bin_id); + api.post('/load-bin', {bin_id}) + .then(res=>{ + dispatch('bin-loaded', res.bin); + }); api.post('/load-notes', {bin_id}) .then(res => { dispatch('notes-loaded', res.notes); @@ -29,4 +31,4 @@ const hash_change_handler = function(state, dispatch, e){ } }; -export {load_bin_handler, hash_change_handler}; \ No newline at end of file +export {initial_load_bin_handler, hash_change_handler}; \ No newline at end of file diff --git a/nginx/public_html/index.js b/nginx/public_html/index.js index 13cda4b..6bcd45a 100644 --- a/nginx/public_html/index.js +++ b/nginx/public_html/index.js @@ -1,6 +1,7 @@ +import api from './api.js'; import App from './App.js'; import nanoid from './nanoid.min.js'; -import {load_bin_handler, hash_change_handler} from './handlers/index.js'; +import {initial_load_bin_handler, hash_change_handler} from './handlers/index.js'; import {bin, note} from './models.js'; const produce = immer.produce; immer.setAutoFreeze(false); // needed for high-frequency updated values, like onkeyup->note.temp_text; only once 'save' is called will it produce a new immutable state tree @@ -23,14 +24,25 @@ if(bin_id === ''){ } // the actual loading from server is done later, after store and dispatch are defined +function addToBinListIfLoggedIn(state){ + const s = state; + // if user is logged in: + if(s.login.is_logged_in){ + // if bin is not already in the list: + if(s.login.bins.filter(b=>b.id===s.bin.id).length===0){ + s.login.bins.push({id: s.bin.id, name: s.bin.name}); + } + } + } + const reducer = handleActions({ - 'new-bin': (s, bin) => { s.bin=bin; s.notes=[]; }, + 'new-bin': (s, bin) => { s.bin=bin; s.notes=[]; s.temp_bin_name=bin.id; }, 'bin-requested': (s, bin_id) => { s.bin={id:bin_id}; s.notes = []; }, - 'bin-loaded': (s, {bin, _notes}) => { s.bin=bin; s.temp_bin_name=bin.name; s.notes=_notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: bin.id, note:n})); }, + 'bin-loaded': (s, bin) => { s.bin=bin; s.temp_bin_name=bin.name; }, 'update-search-term': (s, search_term) => { s.search_term=search_term; }, 'update-search-results': (s, _notes) => { s.notes =_notes.map(n=>({is_editing: false, temp_text: '', bin_id: s.bin.id, note:n})); }, 'add-note': (s, {id, date}) => { s.notes.unshift({is_editing: true, temp_text: '', bin_id: s.bin.id, is_focused:true, note: {id: id, text: '', modified: date}}); }, - 'notes-loaded': (s, _notes) => { s.notes = _notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: s.bin_id, note:n})); }, + 'notes-loaded': (s, _notes) => { s.notes = _notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: s.bin.id, note:n})); }, 'update-note-text': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.note.text=text; }, // updates underlying note text (i.e. the "model", not the app note_state) "in the background" (e.g. from a server-pushed update), regardless of whether it's being edited; "save" is a separate action, below 'update-note-editing': (s, {id, is_editing}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.is_editing=is_editing; note_s.temp_text=note_s.note.text; }, 'save-note-edit': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note.id===id); note_s.note.text=text; note_s.temp_text=text; note_s.is_editing=false; }, @@ -38,12 +50,13 @@ const reducer = handleActions({ 'update-username': (s, username) => { s.login.username=username; }, 'update-password': (s, password) => { s.login.password=password; }, 'login-requested': (s) => { s.login.showing=false; }, - 'login-succeeded': (s, user, session_id) => { s.login.is_logged_in=true; s.login.showing=false; s.login.password=''; s.login.user=user; s.login.session_id=session_id; }, + 'login-succeeded': (s, {user, session_id}) => { s.login.is_logged_in=true; s.login.showing=true; s.login.password=''; s.login.user=user; s.login.session_id=session_id; }, 'login-failed': (s) => { s.login.showing=true; s.login.password=''; }, - 'logout-requested': (s) => { s.login.is_logged_in=false; s.login.showing=true; s.login.username=''; s.login.password=''; s.login.session_id=''; }, + 'logout-requested': (s) => { s.login.is_logged_in=false; s.login.showing=true; s.login.username=''; s.login.password=''; s.login.session_id=''; s.login.bins=[]; }, + 'user-bin-list-loaded': (s, bins) => { s.login.bins=bins; }, 'update-bin-name-editing': (s, is_editing) => { s.is_editing_bin_name=is_editing; }, 'update-bin-name': (s, name) => { s.temp_bin_name=name; }, - 'commit-bin-name': (s) => { s.bin.name=s.temp_bin_name; s.is_editing_bin_name=false; } + 'commit-bin-name': (s) => { s.bin.name=s.temp_bin_name; s.is_editing_bin_name=false; addToBinListIfLoggedIn(s); } }, { bin: {id: bin_id, name: bin_id, user_id: ''}, is_editing_bin_name: false, @@ -62,7 +75,8 @@ const reducer = handleActions({ id: '', username: '' }*/, - session_id: '' + session_id: '', + bins: [] } //search_result_notes: [] }); @@ -73,13 +87,16 @@ const store = Redux.createStore(reducer, /* preloadedState, */ const dispatch = function(action_type, payload){ store.dispatch({type:action_type, payload}); }; store.subscribe(()=>{ - m.render(root, m(App, {state: store.getState(), dispatch})); + const state = store.getState(); + m.render(root, m(App, {state, dispatch})); }); +api.setStore(store); + window.addEventListener("hashchange", (e)=>hash_change_handler(store.getState(), dispatch, e), false); let state = store.getState(); -load_bin_handler(state, dispatch, bin_id); +initial_load_bin_handler(state, dispatch, bin_id); // we don't want Mithril auto-redraw system in place, since Redux will manually re-render when necessary with store.subscribe(): // m.mount(root, App); diff --git a/nginx/public_html/models.js b/nginx/public_html/models.js index 078bca8..7f75cbe 100644 --- a/nginx/public_html/models.js +++ b/nginx/public_html/models.js @@ -11,6 +11,7 @@ function note(o){ n.id = o.id; n.text = o.text || ''; n.modified = o.modified || 0; + n.bin_id = o.bin_id || ''; return n; } diff --git a/node/server.js b/node/server.js index 749f655..2900359 100644 --- a/node/server.js +++ b/node/server.js @@ -14,19 +14,17 @@ const db = new Pool({ const app = express(); const port = 80; -db.query("CREATE TABLE IF NOT EXISTS bin (id VARCHAR PRIMARY KEY);" +db.query("CREATE TABLE IF NOT EXISTS bin (id VARCHAR PRIMARY KEY, name VARCHAR);" + "CREATE TABLE IF NOT EXISTS note (id VARCHAR PRIMARY KEY, text TEXT, modified TIMESTAMPTZ);" + "CREATE TABLE IF NOT EXISTS bin_note (bin_id VARCHAR REFERENCES bin (id), note_id VARCHAR REFERENCES note (id), PRIMARY KEY (bin_id, note_id));" + "CREATE TABLE IF NOT EXISTS user_ (id VARCHAR PRIMARY KEY, username TEXT, password TEXT);" // table 'user' is already taken + + "CREATE TABLE IF NOT EXISTS bin_user (bin_id VARCHAR REFERENCES bin (id), user_id VARCHAR REFERENCES user_ (id), PRIMARY KEY (bin_id, user_id));" + + "CREATE TABLE IF NOT EXISTS session (id VARCHAR PRIMARY KEY, user_id VARCHAR REFERENCES user_ (id));" ); const router = express.Router(); -router.get('/', (req, res)=>{ - res.send('Feh') - }); - const load_notes_stmt = "SELECT n.id, n.text, n.modified FROM bin_note AS bn" +" JOIN note AS n" @@ -37,7 +35,26 @@ router.post('/load-notes', (req, res)=>{ const bin_id = req.body.bin_id; db.query(load_notes_stmt, [bin_id]) .then(result => { - res.json({status: 'ok', bin:{id:bin_id}, notes:result.rows}) + res.json({success:true, notes:result.rows}) + }); + }); + +const load_bin_stmt = + "SELECT b.id, b.name FROM bin AS b" + +" WHERE b.id = $1"; +// {bin_id} +router.post('/load-bin', (req, res)=>{ + const bin_id = req.body.bin_id; + db.query(load_bin_stmt, [bin_id]) + .then(result => { + const bin = result.rows[0]; + // if a bin with given id was found: + if(result.rows.length>0){ + res.json({success:true, bin:{id:bin.id, name:bin.name}}); + } + else{ + res.json({success:false, bin:{id:bin_id, name:bin_id}}); + } }); // {status: 'ok', bin:{id:bin.id}, notes: bin.notes} }); @@ -61,24 +78,33 @@ const upsert_note_stmt = +" ON CONFLICT (id)" +" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified"; const upsert_bin_stmt = - "INSERT INTO bin (id) VALUES ($1)" + "INSERT INTO bin (id, name) VALUES ($1, $1)" +" ON CONFLICT (id)" +" DO NOTHING"; const upsert_bin_note_stmt = "INSERT INTO bin_note (bin_id, note_id) VALUES ($1, $2)" +" ON CONFLICT (bin_id, note_id)" +" DO NOTHING"; - +const upsert_bin_user_stmt = + "INSERT INTO bin_user (bin_id, user_id)" + +" (SELECT $1, s.user_id FROM session AS s WHERE s.id = $2)" + +" ON CONFLICT (bin_id, user_id)" + +" DO NOTHING"; // {bin_id, note_id, text} router.post('/save', (req, res)=>{ - const {bin_id, note_id, text} = req.body; + const {bin_id, note_id, text, session_id} = req.body; db.connect().then(async (client)=>{ + // TODO: make the following into a transaction: await client.query(upsert_note_stmt, [note_id, text]); await client.query(upsert_bin_stmt, [bin_id]); await client.query(upsert_bin_note_stmt, [bin_id, note_id]); + // if the user is signed-in, ensure the bin and user are associated: + if(session_id !== ''){ + await client.query(upsert_bin_user_stmt, [bin_id, session_id]); + } // don't forget to release back into the Pool: client.release(); - res.json({status: 'ok'}); + res.json({success:true}); }); // {status: 'ok'} }); @@ -88,17 +114,32 @@ const login_check_stmt = +" WHERE u.username = $1"; const register_stmt = "INSERT INTO user_ (id, username, password) VALUES ($1, $2, $3)"; +const session_create_stmt = + "INSERT INTO session (id, user_id) VALUES ($1, $2)"; +const user_bin_list_stmt = + "SELECT b.id, b.name FROM bin_user AS bu" + +" INNER JOIN bin AS b" + +" ON bu.bin_id = b.id" + +" WHERE bu.user_id = $1"; router.post('/login', (req, res)=>{ - const {username, password_from_client} = req.body; + const {username, password} = req.body; + const password_from_client=password; db.query(login_check_stmt, [username]) .then(result=>{ // if there is such a username: if(result.rows.length > 0){ const row = result.rows[0]; - const id=row.id, password_from_db=row.password; + const user_id=row.id, password_from_db=row.password; // if the passwords match: + // TODO: replace `===` with a constant-time comparison function to prevent timing attacks; or better, store passwords as salted hashes and use `===` on that: if(password_from_client === password_from_db){ - res.json({success:true, user:{id, username}, session_id:nanoid()}); + const session_id = nanoid(); + db.query(session_create_stmt, [session_id, user_id]); + db.query(user_bin_list_stmt, [user_id]) + .then(result=>{ + const bins = result.rows; + res.json({success:true, user:{id:user_id, username}, session_id, bins}); + }); } else{ res.json({success: false}); @@ -106,15 +147,41 @@ router.post('/login', (req, res)=>{ } // if no such user exists: else{ - const id = nanoid(); - db.query(register_stmt, [id, username, password_from_client]) + const user_id = nanoid(); + const session_id = nanoid(); + db.query(register_stmt, [user_id, username, password_from_client]) .then(r=>{ - res.json({success: true, user:{id, username}, session_id:nanoid()}); + db.query(session_create_stmt, [session_id, user_id]); + res.json({success: true, user:{id:user_id, username}, session_id, bins:[]}); }); } }); }); +const delete_session_stmt = + "DELETE FROM session WHERE id = $1"; +router.post('/logout', (req, res)=>{ + db.query(delete_session_stmt, [req.body.session_id]); + res.json({success:true}); + }); + +const rename_bin_stmt = + "INSERT INTO bin (id, name) VALUES ($1, $2)" + +" ON CONFLICT (id)" + +" DO UPDATE SET name = EXCLUDED.name;"; +router.post('/bin-rename', (req,res)=>{ + const {bin_id, name, session_id} = req.body; + db.query(rename_bin_stmt, [bin_id, name]) + .then(x=>{ + // ensure user and bin are associated if necessary: + if(session_id !== ''){ + // `upsert_bin_user_stmt` is defined above: + db.query(upsert_bin_user_stmt, [bin_id, session_id]); + } + res.json({success:true}); + }); + }); + app.use('/', express.json(), router);