Compare commits

..

No commits in common. 'master' and 'docker-compose' have entirely different histories.

1
.gitignore vendored

@ -107,4 +107,3 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
pgdata

@ -35,9 +35,7 @@ The user may type-in his username/password to sign-in; in which case a list of h
## TODO
- remove 'unauthorized bin' error upon proper login, and when current bin is changed (by hash location, or by 'new bin' button)
- remove immer comment which attempts to load sourcemap
- add favicon.ico to nginx/public_html
- implement Postgres DB migration system ('up'/'down' scripts)
- rename current bin
- optional user sign-in (for listing bins)
- add pagination
- search term highlighting in result note text

@ -15,9 +15,7 @@ services:
build:
context: ./node/
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"
environment:
- NODE_ENV=development
@ -28,16 +26,13 @@ services:
depends_on:
- postgres
working_dir: /home/node/app
# 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"
command: sh -c "yarn install && npx nodemon server.js"
postgres:
image: "postgres:13.1-alpine"
environment:
- POSTGRES_USER=pastebin
- POSTGRES_PASSWORD=buginoo
volumes:
- ./postgres/pgdata:/var/lib/postgresql/data
#volumes:
# - ./postgres/pgdata:/var/lib/postgresql/data
expose:
- "5432"

@ -1,76 +1,31 @@
import Note from './Note.js';
import {
preventDblClickSelection,
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';
import {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler} from './handlers/App.js';
function App(vnode_init){
const {dispatch} = vnode_init.attrs;
const {state, 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); }
const o1 = function(handler, p1){ return handler.bind(null, s, dispatch, p1); }
return m('.app', {key: 'app'}, [
m('.error-message', {key:'error-message'}, s.error_message),
m('.top', {key: 'top'}, [
m('.top-left', {key: 'top-left'}, [
m('button', {key: 'button', onclick: o(new_note_handler)}, 'New Note...'),
m('input.search', {key: 'search', value: s.search_term, onkeyup: o(search_term_change_handler)}),
m('select.sorting', {key: 'sorting', value: s.sorting, onchange: o(sorting_change_handler)}, [
m('button', {key: 'button', onclick: new_note_handler.bind(null, s, dispatch)}, 'New Note...'),
m('input.search', {key: 'search', value: s.search_term, onkeyup: search_term_change_handler.bind(null, s, dispatch)}),
m('select.sorting', {key: 'sorting', value: s.sorting, onchange: sorting_change_handler.bind(null, s, dispatch)}, [
m('option', {key: 'new->old', value: 'new->old'}, 'Newest -> Oldest'),
m('option', {key: 'old->new', value: 'old->new'}, 'Oldest -> Newest')
])
]),
m('.top-right', {key: 'top-right'}, [
(s.login.showing
? (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('.bin-id', {key: 'bin-id'}, s.bin.id),
m('button', {key: 'new-bin-button', onclick: new_bin_handler.bind(null, s, dispatch)}, 'New Bin...')
])
]),
m('.main', {key: 'main'}, [
m('.notes', {ondblclick: o(new_note_by_dblclick_handler), onmousedown:preventDblClickSelection}, s.notes.map(note_state =>
m(Note, {state:s, note_state, dispatch, key: note_state.note_id})
m('.notes', s.notes.map(note_state =>
m(Note, {state, note_state, dispatch, key: note_state.note.id})
))
])
]);

@ -4,14 +4,13 @@ function Note(vnode_init){
const {dispatch} = vnode_init.attrs;
return {
view: function(vnode){
const {state, note_state} = vnode.attrs;
const {is_editing, is_focused, note_id} = note_state;
const note = state.db[note_id].model;
const {note_state} = vnode.attrs;
const {is_editing, is_focused, note} = note_state;
if(is_editing){
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('.buttons', {key: 'editing-buttons'}, [
m('button', {key: 'cancel-button', onclick: cancel_handler.bind(null, state, note_state, dispatch) }, 'Cancel'),
m('button', {key: 'cancel-button', onclick: cancel_handler.bind(null, note_state, dispatch) }, 'Cancel'),
m('button', {key: 'save-button', onclick: save_handler.bind(null, note_state, dispatch) }, 'Save')
])
]);

@ -3,17 +3,9 @@
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;
};
let prefix = "/api";
api.post = function(url, body){
body = body || {};
body.session_id = store.getState().login.session_id;
return m.request({
method: 'POST',
url: prefix+url,

@ -1,25 +1,14 @@
import nanoid from '../nanoid.min.js';
import api from '../api.js';
const preventDblClickSelection = function(e){
// as per [https://stackoverflow.com/a/43321596]
if(e.detail>1){
e.preventDefault();
}
}
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=>{
if(res.success===true){
dispatch('notes-loaded', res.notes);
}
else if(res.authorized===false){
dispatch('notes-unauthorized');
}
dispatch('notes-loaded', res.notes);
});
};
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=>{
dispatch('update-search-results', res.notes);
});
@ -27,10 +16,6 @@ const runSearch = function(state, dispatch){
const new_note_handler = function(state, dispatch){
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){
if(e.code === 'Enter'){
runSearch(state, dispatch);
@ -47,81 +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, 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=>{
if(res.success===true){
dispatch('bin-loaded', res.bin);
api.post('/load-notes', {bin_id})
.then(res=>{
if(res.success===true && res.authorized===true){
dispatch('notes-loaded', res.notes);
}
else if(res.success===false && res.authorized===false){
dispatch('notes-unauthorized');
}
});
}
else if(res.success===false && res.authorized==false){
dispatch('bin-unauthorized');
}
});
dispatch('new-bin', {id});
};
export {preventDblClickSelection,
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
};
export {new_note_handler, search_term_change_handler, sorting_change_handler, load_notes, new_bin_handler};

@ -1,23 +1,17 @@
import api from '../api.js';
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(state, note_state, dispatch){
// TODO: this `if` may cause glitches; keep in mind
if(note_state.is_new===true){
dispatch('immediately-cancel-note', note_state.note_id);
}
else{
dispatch('update-note-editing', {id: note_state.note_id, is_editing: false});
}
const cancel_handler = function(note_state, dispatch){
dispatch('update-note-editing', {id: note_state.note.id, is_editing: false});
};
const text_change_handler = function(note_state, dispatch, e){
note_state.temp_text = e.target.value;
};
const save_handler = function(note_state, dispatch){
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})
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})
};
export { edit_handler, cancel_handler, text_change_handler, save_handler };

@ -1,20 +1,15 @@
import api from '../api.js';
const initial_load_bin_handler = function(state, dispatch, bin_id){
api.post('/load-bin', {bin_id})
const load_bin_handler = function(state, dispatch, bin_id){
/*
api.post('/load-bin', {id: bin_id})
.then(res => {
if(res.success===false && res.authorized===false){
dispatch('bin-unauthorized');
}
else{
dispatch('bin-loaded', res.bin);
}
dispatch('bin-loaded', {bin:res.bin, _notes:res.notes});
});
*/
api.post('/load-notes', {bin_id})
.then(res => {
if(res.success===true){
dispatch('notes-loaded', res.notes);
}
dispatch('notes-loaded', res.notes);
});
};
@ -22,27 +17,16 @@ const hash_change_handler = function(state, dispatch, e){
// get bin id from URL
let bin_id = window.location.hash.substring(1); // extract the leading '#'
if(bin_id === ''){
const old_bin_id = state.bin_id;
const old_bin_id = state.bin.id;
window.history.replaceState(null,'', '#'+old_bin_id);
}
else{
dispatch('bin-requested', bin_id);
api.post('/load-bin', {bin_id})
.then(res=>{
if(res.success==true){
dispatch('bin-loaded', res.bin);
}
else if(res.authorized===false){
dispatch('bin-unauthorized');
}
});
api.post('/load-notes', {bin_id})
.then(res => {
if(res.success==true){
dispatch('notes-loaded', res.notes);
}
dispatch('notes-loaded', res.notes);
});
}
};
export {initial_load_bin_handler, hash_change_handler};
export {load_bin_handler, hash_change_handler};

@ -14,16 +14,6 @@ body{
}
/* end full-page app */
.error-message{
position: fixed;
top: 0px;
margin-top: 1rem;
width: 40%;
margin-right: auto;
margin-left: auto;
background-color: #ee8888;
}
.app{
height: 100%;
width: 100%;
@ -46,93 +36,19 @@ body{
}
.app > .main{
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
flex: 1;
}
.app > .main > .notes{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
flex: 1;
}
.note{
width: 17rem;
height: 17rem;
border: 1px solid black;
border-radius: 1rem;
background-color: #aaccaa;
padding: 0.6rem;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: space-between;
animation: bounce-in-fwd 0.8s ease-in-out both;
}
.note > textarea{
flex: 1;
resize: none;
width: 100%;
background-color: #aaccaa;
border: none;
outline: none;
}
/* ----------------------------------------------
* Generated by Animista on 2021-4-12 15:12:16
* Licensed under FreeBSD License.
* See http://animista.net/license for more info.
* w: http://animista.net, t: @cssanimista
* ---------------------------------------------- */
/**
* ----------------------------------------
* animation bounce-in-fwd
* ----------------------------------------
*/
@keyframes bounce-in-fwd {
0% {
transform: scale(0);
animation-timing-function: ease-in;
opacity: 0;
}
38% {
transform: scale(1);
animation-timing-function: ease-out;
opacity: 1;
}
55% {
transform: scale(0.7);
animation-timing-function: ease-in;
}
72% {
transform: scale(1);
animation-timing-function: ease-out;
}
81% {
transform: scale(0.84);
animation-timing-function: ease-in;
}
89% {
transform: scale(1);
animation-timing-function: ease-out;
}
95% {
transform: scale(0.95);
animation-timing-function: ease-in;
}
100% {
transform: scale(1);
animation-timing-function: ease-out;
}
}
}

@ -2,7 +2,10 @@
<html lang="en">
<head>
<title>Tabler</title>
<!--<link rel="stylesheet" href="tachyons.min.css"></link>-->
<link rel="stylesheet" href="index.css"></link>
<!--<link rel="stylesheet" href="mini-default.min.css"></link>-->
<!--<link rel="stylesheet" href="animate.min.css"></link>-->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="./mithril.min.js"></script>

@ -1,8 +1,6 @@
import api from './api.js';
import App from './App.js';
import nanoid from './nanoid.min.js';
import {initial_load_bin_handler, hash_change_handler} from './handlers/index.js';
import {bin, note} from './models.js';
import {load_bin_handler, hash_change_handler} from './handlers/index.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
@ -24,132 +22,25 @@ if(bin_id === ''){
}
// 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`
*/
// TODO: doesn't update anything that references the deleted record; results in dangling pointers:
const disintegrate = function(s, id){
delete s.db[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({
'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) => { i(s,{id:bin_id}); ref(s,s,'bin_id',bin_id); s.notes = []; s.error_message=''; },
'bin-loaded': (s, bin) => { i(s,bin); ref(s,s,'bin_id',bin.id); s.temp_bin_name=bin.name; },
'bin-unauthorized': (s) => { s.error_message = 'Not authorized to load bin. Please sign-in.'; },
'new-bin': (s, bin) => { s.bin=bin; s.notes=[]; },
'bin-requested': (s, bin_id) => { 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})); },
'update-search-term': (s, search_term) => { s.search_term=search_term; },
'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}) => { i(s, {id: id, text: '', modified: date}); s.notes.unshift({is_editing: true, temp_text: '', bin_id: s.bin_id, is_focused:true, is_new: true, note_id: id}); },
'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})); },
'notes-unauthorized': (s) => { s.error_message = 'Not authorized to load notes in bin. Please sign-in.'; },
'immediately-cancel-note': (s, note_id) => { disintegrate(s,note_id); s.notes.splice(s.notes.findIndex(n=>n.note_id===note_id), 1); },
'update-note-text': (s, {id, text}) => { const note_s=s.notes.find(n=>n.note_id===id); note_s.is_new=false; 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=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); 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-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); }
'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})); },
'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; },
'update-sorting': (s, sorting) => { s.sorting=sorting; }
}, {
bin_id: bin_id,
is_editing_bin_name: false,
temp_bin_name: bin_id,
bin: {id: bin_id},
notes: [
//{is_editing: false, temp_text: '', bin_id: '', note_id: ''},
//{is_editing: false, temp_text: '', bin_id: '', note: {id: nanoid(), text: 'Note one', modified: 1}},
],
search_term: '',
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: ''}
}
},
error_message: ''
sorting: 'new->old'
//search_result_notes: []
});
@ -159,16 +50,13 @@ const store = Redux.createStore(reducer, /* preloadedState, */
const dispatch = function(action_type, payload){ store.dispatch({type:action_type, payload}); };
store.subscribe(()=>{
const state = store.getState();
m.render(root, m(App, {state, dispatch}));
m.render(root, m(App, {state: store.getState(), dispatch}));
});
api.setStore(store);
window.addEventListener("hashchange", (e)=>hash_change_handler(store.getState(), dispatch, e), false);
let state = store.getState();
initial_load_bin_handler(state, dispatch, bin_id);
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);

@ -1,21 +0,0 @@
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
}

@ -6,12 +6,8 @@
"repository": "https://git.sakal.us/brian/pastebin-server.git",
"author": "Brian Sakal <brian@sakal.us>",
"license": "MIT",
"devDependencies": {
"pm2": "^4.5.5"
},
"dependencies": {
"express": "^4.17.1",
"nanoid": "^3.1.22",
"pg": "^8.5.1"
}
}

@ -1,5 +1,4 @@
const express = require('express');
const {nanoid} = require('nanoid');
const { Pool } = require('pg');
const db = new Pool({
@ -14,216 +13,74 @@ const db = new Pool({
const app = express();
const port = 80;
db.query("CREATE TABLE IF NOT EXISTS bin (id VARCHAR PRIMARY KEY, name VARCHAR);"
db.query("CREATE TABLE IF NOT EXISTS bin (id VARCHAR PRIMARY KEY);"
+ "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"
+" ON n.id = bn.note_id"
+" WHERE bn.bin_id = $1";
const load_bin_stmt =
"SELECT b.id, b.name, bu.user_id as owner_user_id, s.user_id as session_user_id 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"
+" FULL JOIN session AS s"
+" ON s.id = $2"
+" WHERE b.id = $1";
const search_stmt =
"SELECT n.id, n.text, n.modified FROM note AS n"
+" INNER JOIN bin_note AS bn"
+" 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";
const upsert_note_stmt =
"INSERT INTO note (id, text, modified) VALUES ($1, $2, NOW())"
+" ON CONFLICT (id)"
+" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified";
const upsert_bin_stmt =
"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";
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";
const delete_session_stmt =
"DELETE FROM session WHERE id = $1";
const rename_bin_stmt =
"INSERT INTO bin (id, name) VALUES ($1, $2)"
+" ON CONFLICT (id)"
+" DO UPDATE SET name = EXCLUDED.name;";
// {bin_id}
router.post('/load-notes', (req, res)=>{
const {bin_id, session_id} = req.body;
db.query(load_bin_stmt, [bin_id, session_id])
.then(bin_result =>{
if(bin_result.rows.length > 0){
const bin = bin_result.rows[0];
// if it's a public bin:
if(bin.owner_user_id===null){
db.query(load_notes_stmt, [bin_id])
.then(result => {
res.json({success:true, authorized:false, notes:result.rows})
});
}
// if it's a private bin, and the owner is signed-in:
else if(bin.owner_user_id===bin.session_user_id){
db.query(load_notes_stmt, [bin_id])
.then(result => {
res.json({success:true, authorized:true, notes:result.rows})
});
}
else{
res.json({success:false, authorized:false});
}
}
});
});
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])
const bin_id = req.body.bind_id;
db.query(load_notes_stmt, [bin_id])
.then(result => {
const bin = result.rows[0];
// if a bin with given id was found:
if(result.rows.length>0){
const bin = result.rows[0];
// if it's a public bin:
if(bin.owner_user_id===null){
res.json({success:true, authorized:false, bin:{id:bin.id, name:bin.name}});
}
// if it's a private bin, and the owner is signed-in:
else if(bin.owner_user_id===bin.session_user_id){
res.json({success:true, authorized:true, bin:{id:bin.id, name:bin.name}});
}
else{
res.json({success:false, authorized:false});
}
}
else{
res.json({success:true, authorized:true, bin:{id:bin_id, name:bin_id}});
}
res.json({status: 'ok', bin:{id:bin_id}, notes:result.rows})
});
// {status: 'ok', bin:{id:bin.id}, notes: bin.notes}
});
const search_stmt =
"SELECT n.id FROM note AS n"
+" WHERE n.text LIKE '%$1%'"
+" ORDER BY n.modified DESC";
// {search_term, sorting, bin_id}
router.post('/search', (req, res)=>{
const {search_term, bin_id, sorting} = req.body;
db.query(search_stmt+(sorting==='old->new'?order_asc_stmt:order_desc_stmt), [search_term, bin_id])
const search_term = req.body.search_term;
db.query(search_stmt, [search_term])
.then(result => {
res.json({success:true, notes:result.rows});
res.json({status:'ok', notes:result.rows.map(n=>n.id)});
});
// {status: 'ok', notes}
});
const upsert_note_stmt =
"INSERT INTO note (id, text, modified) VALUES ($1, $2, NOW())"
+" ON CONFLICT (id)"
+" DO UPDATE SET text = EXCLUDED.text, modified = EXCLUDED.modified";
const upsert_bin_stmt =
"INSERT INTO bin (id) VALUES ($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";
// {bin_id, note_id, text}
router.post('/save', (req, res)=>{
const {bin_id, note_id, text, session_id} = req.body;
const {bin_id, note_id, text} = 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({success:true});
res.json({status: 'ok'});
});
// {status: 'ok'}
});
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:[]});
});
}
});
});
router.post('/logout', (req, res)=>{
db.query(delete_session_stmt, [req.body.session_id]);
res.json({success:true});
});
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);

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save