15 Commits

Author SHA1 Message Date
Brian Sakal edc7830a3d changed docker-compose.yml to be production 2021-04-11 16:14:59 -05:00
brian 9309b6b313 fixed bug: ref_count not updated on user-bin-list-loaded 2021-04-11 17:04:25 -04:00
brian ce6a80f707 fixed bug: ref_count not updated on user-bin-list-loaded 2021-04-11 17:00:13 -04:00
brian 73f5e16f30 local DB row store on frontend 2021-04-11 16:27:47 -04:00
Brian Sakal b75e127625 bin loading restricted for private bins but unrestricted for public bins 2021-04-08 16:22:06 -04:00
Brian Sakal f3e4dab88a logout clears loads new blank bin 2021-04-08 14:18:20 -04:00
Brian Sakal c7195b4209 search order ASC and DESC 2021-04-08 14:14:48 -04:00
brian fdbf3485de load bin from user's list 2021-04-08 09:00:28 -04:00
brian 08f500f67d user bin listing 2021-04-08 08:24:47 -04:00
Brian Sakal cfb21fd2dc closer to user login/register 2021-04-06 18:10:53 -04:00
brian d7e0d4ffd4 began username/password/bin-renaming 2021-04-06 09:02:33 -04:00
brian 27861adeb8 pm2 only for dev; production uses docker-compose auto-restart 2021-03-25 15:52:47 -04:00
brian 90f5466317 switched nodemon to pm2; replies with full note object, not just id 2021-03-25 12:46:22 -04:00
brian 6698e8cf66 api prefix must be relative 2021-03-25 11:57:42 -04:00
brian b27b57805f minor var misspelling 2021-03-24 13:19:11 -04:00
12 changed files with 1654 additions and 74 deletions
+7 -2
View File
@@ -15,10 +15,12 @@ services:
build: build:
context: ./node/ context: ./node/
dockerfile: Dockerfile dockerfile: Dockerfile
# production: no reason to use pm2 if docker offers auto-restart
# development: don't use docker auto-restart; rather pm2, which can watch for file changes
restart: always restart: always
user: "node" user: "node"
environment: environment:
- NODE_ENV=development - NODE_ENV=production
volumes: volumes:
- ./node/:/home/node/app - ./node/:/home/node/app
expose: expose:
@@ -26,7 +28,10 @@ services:
depends_on: depends_on:
- postgres - postgres
working_dir: /home/node/app working_dir: /home/node/app
command: sh -c "yarn install && npx nodemon server.js" # for production (no file watching):
command: sh -c "yarn install && node server.js"
# for development (file watching/reloading):
# command: sh -c "yarn install && yarn pm2-dev server.js"
postgres: postgres:
image: "postgres:13.1-alpine" image: "postgres:13.1-alpine"
environment: environment:
+52 -9
View File
@@ -1,31 +1,74 @@
import Note from './Note.js'; import Note from './Note.js';
import {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler} from './handlers/App.js'; import {
new_note_handler,
new_note_by_dblclick_handler,
search_term_change_handler,
sorting_change_handler,
load_notes,
new_bin_handler,
bin_name_change_handler,
bin_name_editing_toggle_button_handler,
bin_name_commit_handler,
login_request_handler,
logout_request_handler,
username_change_handler,
password_change_handler,
choose_bin_handler
} from './handlers/App.js';
function App(vnode_init){ function App(vnode_init){
const {state, dispatch} = vnode_init.attrs; const {dispatch} = vnode_init.attrs;
//load_notes(state, dispatch); //load_notes(state, dispatch);
return { return {
view: function(vnode){ view: function(vnode){
const s = vnode.attrs.state; const s = vnode.attrs.state;
const o = function(handler){ return handler.bind(null, s, dispatch); }
const o1 = function(handler, p1){ return handler.bind(null, s, dispatch, p1); }
return m('.app', {key: 'app'}, [ return m('.app', {key: 'app'}, [
m('.top', {key: 'top'}, [ m('.top', {key: 'top'}, [
m('.top-left', {key: 'top-left'}, [ m('.top-left', {key: 'top-left'}, [
m('button', {key: 'button', onclick: new_note_handler.bind(null, s, dispatch)}, 'New Note...'), m('button', {key: 'button', onclick: o(new_note_handler)}, 'New Note...'),
m('input.search', {key: 'search', value: s.search_term, onkeyup: search_term_change_handler.bind(null, s, dispatch)}), m('input.search', {key: 'search', value: s.search_term, onkeyup: o(search_term_change_handler)}),
m('select.sorting', {key: 'sorting', value: s.sorting, onchange: sorting_change_handler.bind(null, s, dispatch)}, [ m('select.sorting', {key: 'sorting', value: s.sorting, onchange: o(sorting_change_handler)}, [
m('option', {key: 'new->old', value: 'new->old'}, 'Newest -> Oldest'), m('option', {key: 'new->old', value: 'new->old'}, 'Newest -> Oldest'),
m('option', {key: 'old->new', value: 'old->new'}, 'Oldest -> Newest') m('option', {key: 'old->new', value: 'old->new'}, 'Oldest -> Newest')
]) ])
]), ]),
m('.top-right', {key: 'top-right'}, [ m('.top-right', {key: 'top-right'}, [
m('.bin-id', {key: 'bin-id'}, s.bin.id), (s.login.showing
m('button', {key: 'new-bin-button', onclick: new_bin_handler.bind(null, s, dispatch)}, 'New Bin...') ? (s.login.is_logged_in
? m('.login-info', {key:'login-info'}, [
m('.text', 'Logged in as '+s.db[s.login.user_id].model.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
? m('input.bin-name-textbox', {key: 'bin-name-textbox', value: s.temp_bin_name, onchange: o(bin_name_change_handler)})
: m('.bin-name', {key: 'bin-name'}, s.db[s.bin_id].model.name)
),
m('button', {key: 'new-bin-button', onclick: o(new_bin_handler)}, 'New Bin...'),
(s.is_editing_bin_name
? 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.bin_ids.map(bin_id=>
m('li.bin', {key:bin_id, onclick:o1(choose_bin_handler, bin_id)}, s.db[bin_id].model.name)
))
]) ])
]), ]),
m('.main', {key: 'main'}, [ m('.main', {key: 'main'}, [
m('.notes', s.notes.map(note_state => m('.notes', {ondblclick: o(new_note_by_dblclick_handler)}, 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})
)) ))
]) ])
]); ]);
+3 -2
View File
@@ -4,8 +4,9 @@ function Note(vnode_init){
const {dispatch} = vnode_init.attrs; const {dispatch} = vnode_init.attrs;
return { return {
view: function(vnode){ view: function(vnode){
const {note_state} = vnode.attrs; const {state, note_state} = vnode.attrs;
const {is_editing, is_focused, note} = note_state; const {is_editing, is_focused, note_id} = note_state;
const note = state.db[note_id].model;
if(is_editing){ if(is_editing){
return m('.note', [ return m('.note', [
m('textarea', {key: 'textarea', onchange: text_change_handler.bind(null, note_state, dispatch), oncreate: is_focused?({dom})=>{ dom.focus(); delete note_state.is_focused; }:null }, note_state.temp_text), m('textarea', {key: 'textarea', onchange: text_change_handler.bind(null, note_state, dispatch), oncreate: is_focused?({dom})=>{ dom.focus(); delete note_state.is_focused; }:null }, note_state.temp_text),
+9 -1
View File
@@ -3,9 +3,17 @@
const api = {}; const api = {};
let prefix = "/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){ api.post = function(url, body){
body = body || {};
body.session_id = store.getState().login.session_id;
return m.request({ return m.request({
method: 'POST', method: 'POST',
url: prefix+url, url: prefix+url,
+71 -4
View File
@@ -2,13 +2,13 @@ import nanoid from '../nanoid.min.js';
import api from '../api.js'; import api from '../api.js';
const load_notes = function(state, dispatch){ const load_notes = function(state, dispatch){
api.post('/load-notes', {bin_id: state.bin.id}) api.post('/load-notes', {bin_id: state.bin_id})
.then(res=>{ .then(res=>{
dispatch('notes-loaded', res.notes); dispatch('notes-loaded', res.notes);
}); });
}; };
const runSearch = function(state, dispatch){ const runSearch = function(state, dispatch){
api.post('/search', {search_term: state.search_term, sorting: state.sorting, bin_id: state.bin.id}) api.post('/search', {search_term: state.search_term, sorting: state.sorting, bin_id: state.bin_id})
.then(res=>{ .then(res=>{
dispatch('update-search-results', res.notes); dispatch('update-search-results', res.notes);
}); });
@@ -16,6 +16,10 @@ const runSearch = function(state, dispatch){
const new_note_handler = function(state, dispatch){ const new_note_handler = function(state, dispatch){
dispatch('add-note', {id: nanoid(), date: Date.now()}); dispatch('add-note', {id: nanoid(), date: Date.now()});
}; };
const new_note_by_dblclick_handler = function(state, dispatch, e){
e.preventDefault(); // for nothing gets highlighted
dispatch('add-note', {id: nanoid(), date: Date.now()});
};
const search_term_change_handler = function(state, dispatch, e){ const search_term_change_handler = function(state, dispatch, e){
if(e.code === 'Enter'){ if(e.code === 'Enter'){
runSearch(state, dispatch); runSearch(state, dispatch);
@@ -32,7 +36,70 @@ const new_bin_handler = function(state, dispatch){
const id = nanoid(); const id = nanoid();
// change browser location in address bar: // change browser location in address bar:
window.history.pushState(null,'','#'+id); 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);
};
const password_change_handler = function(state, dispatch, e){
dispatch('update-password', e.target.value);
};
const login_request_handler = function(state, dispatch, e){
dispatch('login-requested');
api.post('/login', {username: state.login.username, password: state.login.password})
.then(res=>{
if(res.success===true){
dispatch('login-succeeded', {user:res.user, session_id:res.session_id});
dispatch('user-bin-list-loaded', res.bins);
}
else{
dispatch('login-failed');
}
},
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');
// after logout, clear the bin:
new_bin_handler(state, dispatch);
};
const bin_name_editing_toggle_button_handler = function(state, dispatch){
dispatch('update-bin-name-editing', !state.is_editing_bin_name);
};
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');
};
const choose_bin_handler = function(state, dispatch, bin_id){
window.history.pushState(null,'','#'+bin_id);
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);
});
}; };
export {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler}; export {new_note_handler,
new_note_by_dblclick_handler,
search_term_change_handler,
sorting_change_handler,
load_notes,
new_bin_handler,
username_change_handler,
password_change_handler,
login_request_handler,
logout_request_handler,
bin_name_editing_toggle_button_handler,
bin_name_change_handler,
bin_name_commit_handler,
choose_bin_handler
};
+4 -4
View File
@@ -1,17 +1,17 @@
import api from '../api.js'; import api from '../api.js';
const edit_handler = function(note_state, dispatch){ const edit_handler = function(note_state, dispatch){
dispatch('update-note-editing', {id: note_state.note.id, is_editing: true}); dispatch('update-note-editing', {id: note_state.note_id, is_editing: true});
}; };
const cancel_handler = function(note_state, dispatch){ const cancel_handler = function(note_state, dispatch){
dispatch('update-note-editing', {id: note_state.note.id, is_editing: false}); dispatch('update-note-editing', {id: note_state.note_id, is_editing: false});
}; };
const text_change_handler = function(note_state, dispatch, e){ const text_change_handler = function(note_state, dispatch, e){
note_state.temp_text = e.target.value; note_state.temp_text = e.target.value;
}; };
const save_handler = function(note_state, dispatch){ const save_handler = function(note_state, dispatch){
dispatch('save-note-edit', {id: note_state.note.id, text: note_state.temp_text}); dispatch('save-note-edit', {id: note_state.note_id, text: note_state.temp_text});
api.post('/save', {bin_id: note_state.bin_id, note_id: note_state.note.id, text: note_state.temp_text}) api.post('/save', {bin_id: note_state.bin_id, note_id: note_state.note_id, text: note_state.temp_text})
}; };
export { edit_handler, cancel_handler, text_change_handler, save_handler }; export { edit_handler, cancel_handler, text_change_handler, save_handler };
+9 -7
View File
@@ -1,12 +1,10 @@
import api from '../api.js'; import api from '../api.js';
const load_bin_handler = function(state, dispatch, bin_id){ const initial_load_bin_handler = function(state, dispatch, bin_id){
/* api.post('/load-bin', {bin_id})
api.post('/load-bin', {id: bin_id})
.then(res => { .then(res => {
dispatch('bin-loaded', {bin:res.bin, _notes:res.notes}); dispatch('bin-loaded', res.bin);
}); });
*/
api.post('/load-notes', {bin_id}) api.post('/load-notes', {bin_id})
.then(res => { .then(res => {
dispatch('notes-loaded', res.notes); dispatch('notes-loaded', res.notes);
@@ -17,11 +15,15 @@ const hash_change_handler = function(state, dispatch, e){
// get bin id from URL // get bin id from URL
let bin_id = window.location.hash.substring(1); // extract the leading '#' let bin_id = window.location.hash.substring(1); // extract the leading '#'
if(bin_id === ''){ if(bin_id === ''){
const old_bin_id = state.bin.id; const old_bin_id = state.bin_id;
window.history.replaceState(null,'', '#'+old_bin_id); window.history.replaceState(null,'', '#'+old_bin_id);
} }
else{ else{
dispatch('bin-requested', bin_id); dispatch('bin-requested', bin_id);
api.post('/load-bin', {bin_id})
.then(res=>{
dispatch('bin-loaded', res.bin);
});
api.post('/load-notes', {bin_id}) api.post('/load-notes', {bin_id})
.then(res => { .then(res => {
dispatch('notes-loaded', res.notes); dispatch('notes-loaded', res.notes);
@@ -29,4 +31,4 @@ const hash_change_handler = function(state, dispatch, e){
} }
}; };
export {load_bin_handler, hash_change_handler}; export {initial_load_bin_handler, hash_change_handler};
+119 -16
View File
@@ -1,6 +1,8 @@
import api from './api.js';
import App from './App.js'; import App from './App.js';
import nanoid from './nanoid.min.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; 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 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
@@ -22,25 +24,123 @@ if(bin_id === ''){
} }
// the actual loading from server is done later, after store and dispatch are defined // the actual loading from server is done later, after store and dispatch are defined
/* Integrates a database row from the server into the local store: */
const integrate = function(s, model_object){
const id = model_object.id;
s.db[id] = s.db[id] || {ref_count: 0, model: null};
s.db[id].model = model_object;
};
const i = integrate;
/* integrates a list of model objects, as if 'integrate' were called on each element. */
const integrateAll = function(s, model_objects){
model_objects.forEach(mo=>{
integrate(s, mo);
});
};
/* Register a reference-by-id: */
const ref = function(s, o, key, new_model_id){
const old_model_id = o[key];
s.db[new_model_id].ref_count++;
if(old_model_id && s.db[old_model_id]){
s.db[old_model_id].ref_count--;
if(s.db[old_model_id].ref_count === 0){
delete s.db[old_model_id];
}
}
// this needs to come after the above 'if' statement:
o[key] = new_model_id;
};
/* Register references in-bulk: */
/* o[key] refers to an array-of-ids */
const refAll = function(s, o, key, new_model_ids){
const old_model_ids = o[key];
// ref the new refs:
o[key] = new_model_ids;
new_model_ids.forEach(new_model_id=>{
s.db[new_model_id].ref_count++;
});
// de-ref the existing refs, deleting no-longer-referred-to models:
old_model_ids.forEach(old_model_id=>{
s.db[old_model_id].ref_count--;
// if this is the last reference, delete it from the store:
if(s.db[old_model_id].ref_count === 0){
delete s.db[old_model_id];
}
});
};
/* TODO: routine to de-ref and ref in bulk when the id is in an object which is in an array (as opposed to refAll, where the id itself is in the array)
This routine would need to be called for state.notes, which contains refs of the form: `state.notes[n].note_id`
*/
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.bin_ids.filter(bin_id=>bin_id===s.bin_id).length===0){
i(s, {id: s.bin_id, name: s.db[s.bin_id].model.name});
// causes problems because ref_count isn't properly updated:
//s.login.bin_ids.push(s.bin_id);
ref(s, s.login.bin_ids, s.login.bin_ids.length, s.bin_id);
}
}
}
const reducer = handleActions({ const reducer = handleActions({
'new-bin': (s, bin) => { s.bin=bin; s.notes=[]; }, 'new-bin': (s, bin) => { i(s,bin); ref(s,s,'bin_id',bin.id); s.notes=[]; s.temp_bin_name=bin.id; },
'bin-requested': (s, bin_id) => { s.bin={id:bin_id}; s.notes = []; }, 'bin-requested': (s, bin_id) => { i(s,{id:bin_id}); ref(s,s,'bin_id',bin_id); s.notes = []; },
'bin-loaded': (s, {bin, _notes}) => { s.bin=bin; s.notes=_notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: bin.id, note:n})); }, 'bin-loaded': (s, bin) => { i(s,bin); ref(s,s,'bin_id',bin.id); s.temp_bin_name=bin.name; },
'update-search-term': (s, search_term) => { s.search_term=search_term; }, '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})); }, 'update-search-results': (s, _notes) => { integrateAll(s,s.notes,'note_id',_notes); s.notes =_notes.map(n=>({is_editing: false, temp_text: '', bin_id: s.bin_id, note_id:n.id})); },
'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}}); }, 'add-note': (s, {id, date}) => { i(s, {id: id, text: '', modified: date}); s.notes.unshift({is_editing: true, temp_text: '', bin_id: s.bin_id, is_focused:true, note_id: id}); },
'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) => { integrateAll(s,_notes); s.notes = _notes.map(n=>({is_editing: false, temp_text: n.text, bin_id: s.bin_id, note_id:n.id})); },
'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-text': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note_id===id); s.db[note_s.note_id].model.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; }, '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=s.db[note_s.note_id].model.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; }, 'save-note-edit': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note_id===id); s.db[note_s.note_id].model.text=text; note_s.temp_text=text; note_s.is_editing=false; },
'update-sorting': (s, sorting) => { s.sorting=sorting; } 'update-sorting': (s, sorting) => { s.sorting=sorting; },
'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}) => { i(s, user); s.login.is_logged_in=true; s.login.showing=true; s.login.password=''; ref(s, s.login, 'user_id', user.id); 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=''; refAll(s, s.login, 'bin_ids', []); },
'user-bin-list-loaded': (s, bins) => { integrateAll(s, bins); refAll(s, s.login, 'bin_ids', bins.map(b=>b.id)); },
'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.db[s.bin_id].model.name=s.temp_bin_name; s.is_editing_bin_name=false; addToBinListIfLoggedIn(s); }
}, { }, {
bin: {id: bin_id}, bin_id: bin_id,
is_editing_bin_name: false,
temp_bin_name: bin_id,
notes: [ notes: [
//{is_editing: false, temp_text: '', bin_id: '', note: {id: nanoid(), text: 'Note one', modified: 1}}, //{is_editing: false, temp_text: '', bin_id: '', note_id: ''},
], ],
search_term: '', search_term: '',
sorting: 'new->old' sorting: 'new->old',
login: {
showing: true,
username: '', // value of textbox
password: '', // value of textbox
is_logged_in: false,
user_id: '' /*{
id: '',
username: ''
}*/,
session_id: '',
bin_ids: []
},
db: {
[bin_id]: {
ref_count: 1,
model: {id: bin_id, name: bin_id, user_id: ''}
}
}
//search_result_notes: [] //search_result_notes: []
}); });
@@ -50,13 +150,16 @@ const store = Redux.createStore(reducer, /* preloadedState, */
const dispatch = function(action_type, payload){ store.dispatch({type:action_type, payload}); }; const dispatch = function(action_type, payload){ store.dispatch({type:action_type, payload}); };
store.subscribe(()=>{ 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); window.addEventListener("hashchange", (e)=>hash_change_handler(store.getState(), dispatch, e), false);
let state = store.getState(); 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(): // 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); // m.mount(root, App);
+21
View File
@@ -0,0 +1,21 @@
function bin(o){
const b = {};
b.id = o.id;
b.name = o.name || b.id;
b.user_id = o.user_id || '';
return b;
}
function note(o){
const n = {};
n.id = o.id;
n.text = o.text || '';
n.modified = o.modified || 0;
n.bin_id = o.bin_id || '';
return n;
}
export {
bin,
note
}
+4
View File
@@ -6,8 +6,12 @@
"repository": "https://git.sakal.us/brian/pastebin-server.git", "repository": "https://git.sakal.us/brian/pastebin-server.git",
"author": "Brian Sakal <brian@sakal.us>", "author": "Brian Sakal <brian@sakal.us>",
"license": "MIT", "license": "MIT",
"devDependencies": {
"pm2": "^4.5.5"
},
"dependencies": { "dependencies": {
"express": "^4.17.1", "express": "^4.17.1",
"nanoid": "^3.1.22",
"pg": "^8.5.1" "pg": "^8.5.1"
} }
} }
+126 -18
View File
@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const {nanoid} = require('nanoid');
const { Pool } = require('pg'); const { Pool } = require('pg');
const db = new Pool({ const db = new Pool({
@@ -13,18 +14,17 @@ const db = new Pool({
const app = express(); const app = express();
const port = 80; 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 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 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(); const router = express.Router();
router.get('/', (req, res)=>{
res.send('Feh')
});
const load_notes_stmt = const load_notes_stmt =
"SELECT n.id, n.text, n.modified FROM bin_note AS bn" "SELECT n.id, n.text, n.modified FROM bin_note AS bn"
+" JOIN note AS n" +" JOIN note AS n"
@@ -32,26 +32,52 @@ const load_notes_stmt =
+" WHERE bn.bin_id = $1"; +" WHERE bn.bin_id = $1";
// {bin_id} // {bin_id}
router.post('/load-notes', (req, res)=>{ router.post('/load-notes', (req, res)=>{
const bin_id = req.body.bind_id; const bin_id = req.body.bin_id;
db.query(load_notes_stmt, [bin_id]) db.query(load_notes_stmt, [bin_id])
.then(result => { .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"
+" FULL JOIN bin_user AS bu" // we want the bin regardless of whether it has an associated user, hence LEFT JOIN
+" ON bu.bin_id = b.id"
+" INNER JOIN session AS s"
+" ON (bu.bin_id IS NULL) OR (s.id = $2 AND s.user_id = bu.user_id)"
+" WHERE b.id = $1";
router.post('/load-bin', (req, res)=>{
const {bin_id, session_id} = req.body;
// if a bin has no associated user, it's considered public and can be accessed even when not logged-in.
// if a bin has an associated user, it can only be accessed by that user
db.query(load_bin_stmt, [bin_id, session_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:true, bin:{id:bin_id, name:bin_id}});
}
}); });
// {status: 'ok', bin:{id:bin.id}, notes: bin.notes} // {status: 'ok', bin:{id:bin.id}, notes: bin.notes}
}); });
const search_stmt = const search_stmt =
"SELECT n.id FROM note AS n" "SELECT n.id, n.text, n.modified FROM note AS n"
+" WHERE n.text LIKE '%$1%'" +" INNER JOIN bin_note AS bn"
+" ORDER BY n.modified DESC"; +" ON bn.note_id = n.id AND bn.bin_id = $2"
+" WHERE n.text LIKE '%' || $1 || '%'"; // need to use string concat otherwise the `$1` is viewed as part of the string instead of a placeholder
const order_desc_stmt = " ORDER BY n.modified DESC";
const order_asc_stmt = " ORDER BY n.modified ASC";
// {search_term, sorting, bin_id} // {search_term, sorting, bin_id}
router.post('/search', (req, res)=>{ router.post('/search', (req, res)=>{
const search_term = req.body.search_term; const {search_term, bin_id, sorting} = req.body;
db.query(search_stmt, [search_term]) db.query(search_stmt+(sorting==='old->new'?order_asc_stmt:order_desc_stmt), [search_term, bin_id])
.then(result => { .then(result => {
res.json({status:'ok', notes:result.rows.map(n=>n.id)}); res.json({success:true, notes:result.rows});
}); });
// {status: 'ok', notes}
}); });
const upsert_note_stmt = const upsert_note_stmt =
@@ -59,28 +85,110 @@ const upsert_note_stmt =
+" ON CONFLICT (id)" +" ON CONFLICT (id)"
+" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified"; +" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified";
const upsert_bin_stmt = const upsert_bin_stmt =
"INSERT INTO bin (id) VALUES ($1)" "INSERT INTO bin (id, name) VALUES ($1, $1)"
+" ON CONFLICT (id)" +" ON CONFLICT (id)"
+" DO NOTHING"; +" DO NOTHING";
const upsert_bin_note_stmt = const upsert_bin_note_stmt =
"INSERT INTO bin_note (bin_id, note_id) VALUES ($1, $2)" "INSERT INTO bin_note (bin_id, note_id) VALUES ($1, $2)"
+" ON CONFLICT (bin_id, note_id)" +" ON CONFLICT (bin_id, note_id)"
+" DO NOTHING"; +" 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} // {bin_id, note_id, text}
router.post('/save', (req, res)=>{ 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)=>{ 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_note_stmt, [note_id, text]);
await client.query(upsert_bin_stmt, [bin_id]); await client.query(upsert_bin_stmt, [bin_id]);
await client.query(upsert_bin_note_stmt, [bin_id, note_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: // don't forget to release back into the Pool:
client.release(); client.release();
res.json({status: 'ok'}); res.json({success:true});
}); });
// {status: 'ok'} // {status: 'ok'}
}); });
const login_check_stmt =
"SELECT id, password FROM user_ AS u"
+" 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} = 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 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){
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});
}
}
// if no such user exists:
else{
const user_id = nanoid();
const session_id = nanoid();
db.query(register_stmt, [user_id, username, password_from_client])
.then(r=>{
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); app.use('/', express.json(), router);
+1228 -10
View File
File diff suppressed because it is too large Load Diff