Taskboard Live: realtime updates and file attachments with sessions
This guide takes the Taskboard reference one step further. Tasks now accept file attachments, and an authenticated WebSocket pushes every change to the people who are allowed to see it: the owner of the task and the administrators. The page updates by itself, with no refresh.
Everything is native Total.js: the session authentication of the first guide, @upload routes, FILESTORAGE for the files, a SOCKET route that requires a signed-in user and embedded NoSQL for the metadata. The server decides who sees what: a member never receives the events or the files of another member, whatever the browser asks for.
The files below are complete, so copy them into a new directory exactly as in the first guide (demo: true, the three local users, total5@0.0.19, loopback only). The demo rules of that guide apply here too.
Server files
name : Taskboard Live
demo : true
fs : taskboard-filesvar crypto = Total.Crypto;
var demo = CONF.demo === true;
if (demo && (process.argv.includes('--release') || process.env.NODE_ENV === 'production'))
throw new Error('Disable demo mode before a release deployment');
var secret = process.env.TASKBOARD_SECRET || (demo ? 'local-taskboard-demo-secret-not-for-public-use' : '');
if (secret.length < 32)
throw new Error('TASKBOARD_SECRET needs at least 32 characters');
CONF.secret_csrf = secret;
MAIN.taskUsers = Object.create(null);
for (var name of ['alice', 'bob', 'admin']) {
var password = process.env['TASKBOARD_' + name.toUpperCase() + '_PASSWORD'] || (demo ? name + '-demo-password' : '');
if (password.length < 12)
throw new Error('Configure a password of at least 12 characters for ' + name);
var salt = crypto.randomBytes(16).toString('hex');
MAIN.taskUsers[name] = { id: name, name: name, role: name === 'admin' ? 'admin' : 'member', salt: salt, hash: crypto.scryptSync(password, salt, 32) };
}
MAIN.taskAuth = {
secret: secret,
cookie: 'taskboard_session',
expire: '30 minutes',
options: { httponly: true, samesite: 'Strict', secure: !demo },
onread: function(meta, next) {
NOSQL('task-sessions').read().where('id', meta.sessionid).callback(function(err, session) {
var user = !err && session && session.expire > Date.now() && MAIN.taskUsers[session.userid];
user ? next(null, { id: user.id, name: user.name, role: user.role }) : next('Session not found');
});
},
onlogout: function(session) {
NOSQL('task-sessions').remove().where('id', session.sessionid).callback(NOOP);
},
onremove: function(id) {
NOSQL('task-sessions').remove().where('id', id).callback(NOOP);
}
};
AUTH(MAIN.taskAuth);
MIDDLEWARE('taskcsrf', function($, next) {
DEF.onCSRFcheck($) ? next() : $.invalid(403);
});
// Sends an event to the sockets of the owner of a task and to the administrators only
MAIN.live = function(owner, event) {
MAIN.liveSocket && MAIN.liveSocket.send(event, function(client) {
return client.user.role === 'admin' || client.user.id === owner;
});
};function owned(builder, user) {
return user.role === 'admin' ? builder : builder.where('owner', user.id);
}
NEWACTION('Tasks|list', {
user: true,
action: function($) {
owned(NOSQL('tasks').find(), $.user).sort('title').callback(function(err, tasks) {
err ? $.invalid(500) : $.callback(tasks);
});
}
});
NEWACTION('Tasks|create', {
user: true,
input: '*title:String(80)',
action: function($, model) {
var task = { id: UID(), title: model.title, done: false, owner: $.user.id };
NOSQL('tasks').insert(task).callback(function(err) {
if (err)
return $.invalid(500);
MAIN.live(task.owner, { type: 'task', id: task.id });
$.callback(task);
});
}
});
NEWACTION('Tasks|update', {
user: true,
params: '*id:UID',
input: '*done:Boolean',
action: function($, model) {
owned(NOSQL('tasks').update({ done: model.done }).where('id', $.params.id), $.user).callback(function(err, count) {
if (err)
return $.invalid(500);
if (!count)
return $.invalid(404);
NOSQL('tasks').read().where('id', $.params.id).callback(function(err, task) {
task && MAIN.live(task.owner, { type: 'task', id: task.id });
$.success();
});
});
}
});
NEWACTION('Tasks|remove', {
user: true,
params: '*id:UID',
action: function($) {
if ($.user.role !== 'admin')
return $.invalid(403);
NOSQL('tasks').read().where('id', $.params.id).callback(function(err, task) {
if (err)
return $.invalid(500);
if (!task)
return $.invalid(404);
// The attachments of the task go away with it
NOSQL('attachments').find().where('taskid', task.id).callback(function(err, files) {
(files || []).forEach(file => FILESTORAGE(CONF.fs).remove(file.id, NOOP));
NOSQL('attachments').remove().where('taskid', task.id).callback(NOOP);
NOSQL('tasks').remove().where('id', task.id).callback(function(err) {
if (err)
return $.invalid(500);
MAIN.live(task.owner, { type: 'task', id: task.id });
$.success();
});
});
});
}
});var attempts = Object.create(null);
var allowed = ['text/plain', 'image/png', 'application/pdf'];
exports.install = function() {
ROUTE('GET /', function($) { $.file(PATH.public('index.html')); });
ROUTE('GET /session/', session);
ROUTE('POST /login/ @json <2kb #taskcsrf', login);
ROUTE('+POST /logout/ @json #taskcsrf', logout);
ROUTE('+GET /tasks/ --> *Tasks|list');
ROUTE('+POST /tasks/ <2kb #taskcsrf --> *Tasks|create');
ROUTE('+PATCH /tasks/{id}/ <2kb #taskcsrf --> *Tasks|update');
ROUTE('+DELETE /tasks/{id}/ #taskcsrf --> *Tasks|remove');
ROUTE('+GET /tasks/{id}/files/', files);
ROUTE('+POST /tasks/{id}/files/ @upload <1mb #taskcsrf', attach);
ROUTE('+GET /tasks/{id}/files/{fileid}/', download);
ROUTE('+SOCKET /live/', live);
};
// The task must be visible to the user: members only reach their own tasks
function visible($, next) {
var builder = NOSQL('tasks').read().where('id', $.params.id);
if ($.user.role !== 'admin')
builder.where('owner', $.user.id);
builder.callback(function(err, task) {
if (err)
return $.invalid(500);
task ? next(task) : $.invalid(404);
});
}
function files($) {
visible($, function(task) {
NOSQL('attachments').find().where('taskid', task.id).sort('filename').fields('id,filename,size,type').callback(function(err, items) {
err ? $.invalid(500) : $.json(items);
});
});
}
function attach($) {
var file = $.files[0];
if (!file || !allowed.includes(file.type))
return $.invalid(400);
visible($, function(task) {
var id = UID();
file.fs(CONF.fs, id, function(err) {
if (err)
return $.invalid(500);
var meta = { id: id, taskid: task.id, filename: file.filename, size: file.size, type: file.type };
NOSQL('attachments').insert(meta).callback(function(err) {
if (err)
return $.invalid(500);
MAIN.live(task.owner, { type: 'attachment', id: task.id });
$.json({ id: id, filename: meta.filename, size: meta.size, type: meta.type });
});
});
});
}
function download($) {
visible($, function(task) {
NOSQL('attachments').read().where('id', $.params.fileid).where('taskid', task.id).callback(function(err, meta) {
if (err || !meta)
return $.invalid(404);
$.response.headers['x-content-type-options'] = 'nosniff';
$.filefs(CONF.fs, meta.id, true);
});
});
}
// Only a signed-in user reaches this route; the sockets are kept to send events to them
function live() {
MAIN.liveSocket = this;
}
function session($) {
$.response.headers['cache-control'] = 'no-store';
$.json({ user: $.user || null, csrf: $.csrf() });
}
function login($) {
var key = $.ip;
var recent = (attempts[key] || []).filter(time => time > Date.now() - 600000);
attempts[key] = recent;
if (recent.length >= 5) return $.invalid(429);
var user = MAIN.taskUsers[String($.body.name || '').toLowerCase()];
var salt = user ? user.salt : 'dummy-salt';
var actual = Total.Crypto.scryptSync(String($.body.password || ''), salt, 32);
var valid = user && Total.Crypto.timingSafeEqual(actual, user.hash);
if (!valid) {
recent.push(Date.now());
return $.invalid(401);
}
var id = UID();
NOSQL('task-sessions').insert({ id: id, userid: user.id, expire: Date.now() + 1800000 }).callback(function(err) {
if (err) return $.invalid(500);
MAIN.taskAuth.authcookie($, id, user.id, '30 minutes');
$.json({ user: { id: user.id, name: user.name, role: user.role }, csrf: $.csrf() });
});
}
function logout($) {
NOSQL('task-sessions').remove().where('id', $.sessionid).callback(function(err) {
if (err) return $.invalid(500);
MAIN.taskAuth.logout($);
$.cookie(MAIN.taskAuth.cookie, '', '-1 year', MAIN.taskAuth.options);
$.json({ ok: true });
});
}<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Taskboard Live · Total.js</title>
<style>
body{font:16px system-ui;background:#f4f5f7;color:#172033;margin:0}main{max-width:720px;margin:8vh auto;padding:30px;background:white;border-radius:18px}header{display:flex;align-items:center;justify-content:space-between;gap:15px}h1{margin:0}p{color:#536078}label{display:block;margin-top:14px}input,button{font:inherit;padding:10px;border:1px solid #bbc4d1;border-radius:8px}input[type=text],input[type=password]{box-sizing:border-box;width:100%;margin-top:5px}button{cursor:pointer;background:#152d57;color:white}form button{margin-top:15px}ul{padding:0;list-style:none}li.task{padding:14px 0;border-bottom:1px solid #eee}.row{display:flex;align-items:center;gap:10px}.task-title{flex:1;overflow-wrap:anywhere}.muted{font-size:14px;color:#536078}.files{margin:8px 0 0 30px;padding:0;font-size:14px}#message{min-height:24px;color:#9d251e}#live{font-size:13px;color:#2a7a3b}[hidden]{display:none!important}
</style></head><body><main>
<header><h1>Taskboard Live</h1><span class="muted">Total.js reference</span></header>
<p>Private tasks with attachments, updated live for the people who may see them.</p>
<p id="message" role="status" aria-live="polite"></p>
<form id="login"><h2>Sign in</h2><label for="name">User</label><input id="name" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" required><button>Sign in</button></form>
<section id="workspace" hidden><header><p id="identity"></p><span id="live" hidden>● live</span><button id="logout" type="button">Sign out</button></header>
<form id="create"><label for="title">New task</label><input id="title" type="text" maxlength="80" required><button>Add task</button></form>
<ul id="tasks" aria-label="Tasks"></ul><p id="empty">No tasks yet.</p></section>
</main><script src="/app.js"></script></body></html>(function() {
var user = null;
var csrf = '';
var busy = false;
var socket = null;
var byid = id => document.getElementById(id);
async function request(path, method, body) {
var form = body instanceof FormData;
var headers = { 'x-csrf-token': csrf };
if (!form) headers['content-type'] = 'application/json';
var response = await fetch(path, { method: method || 'GET', credentials: 'same-origin', headers: headers, body: body === undefined ? undefined : form ? body : JSON.stringify(body) });
var data = await response.json().catch(() => null);
if (!response.ok) {
if (response.status === 401) { user = null; show(); }
throw new Error('Request refused (' + response.status + ')');
}
if (data === null) throw new Error('Unexpected server response');
return data;
}
function show() {
byid('login').hidden = !!user;
byid('workspace').hidden = !user;
byid('identity').textContent = user ? user.name + ' · ' + user.role : '';
if (!user && socket) { socket.close(); socket = null; byid('live').hidden = true; }
}
// The server pushes an event whenever something the user may see changes
function connect() {
if (socket) return;
socket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/live/');
socket.onopen = () => { byid('live').hidden = false; };
socket.onclose = () => { byid('live').hidden = true; socket = null; };
socket.onmessage = () => { if (user && !busy) load().catch(err => byid('message').textContent = err.message); };
}
async function load() {
var tasks = await request('/tasks/');
var lists = await Promise.all(tasks.map(task => request('/tasks/' + task.id + '/files/')));
byid('tasks').replaceChildren();
byid('empty').hidden = tasks.length > 0;
tasks.forEach(function(task, index) {
var row = document.createElement('li');
row.className = 'task';
var line = document.createElement('div');
line.className = 'row';
var checkbox = document.createElement('input');
checkbox.type = 'checkbox'; checkbox.checked = task.done; checkbox.setAttribute('aria-label', 'Complete ' + task.title);
checkbox.addEventListener('change', () => work(async () => { await request('/tasks/' + task.id + '/', 'PATCH', { done: checkbox.checked }); await load(); }));
var title = document.createElement('span'); title.className = 'task-title'; title.textContent = task.title;
var upload = document.createElement('input');
upload.type = 'file'; upload.accept = '.txt,.png,.pdf'; upload.setAttribute('aria-label', 'Attach a file to ' + task.title);
upload.addEventListener('change', () => work(async () => {
var form = new FormData(); form.append('file', upload.files[0]);
await request('/tasks/' + task.id + '/files/', 'POST', form); await load();
}));
line.append(checkbox, title, upload);
if (user.role === 'admin') {
var remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete';
remove.addEventListener('click', () => work(async () => { await request('/tasks/' + task.id + '/', 'DELETE'); await load(); }));
line.append(remove);
}
row.append(line);
var list = document.createElement('ul'); list.className = 'files';
lists[index].forEach(function(file) {
var item = document.createElement('li');
var link = document.createElement('a');
link.href = '/tasks/' + task.id + '/files/' + file.id + '/'; link.textContent = file.filename;
item.append(link, ' (' + file.size + ' bytes)');
list.append(item);
});
row.append(list);
byid('tasks').append(row);
});
}
async function work(fn) {
if (busy) return;
busy = true; byid('message').textContent = '';
document.querySelectorAll('input,button').forEach(control => control.disabled = true);
try { await fn(); } catch (err) { byid('message').textContent = err.message; }
finally { busy = false; document.querySelectorAll('input,button').forEach(control => control.disabled = false); }
}
byid('login').addEventListener('submit', function(event) {
event.preventDefault(); work(async function() {
var result = await request('/login/', 'POST', { name: byid('name').value, password: byid('password').value });
user = result.user; csrf = result.csrf; byid('password').value = ''; show(); connect(); await load();
});
});
byid('create').addEventListener('submit', function(event) {
event.preventDefault(); work(async function() { await request('/tasks/', 'POST', { title: byid('title').value }); byid('title').value = ''; await load(); });
});
byid('logout').addEventListener('click', () => work(async function() { await request('/logout/', 'POST', {}); user = null; byid('tasks').replaceChildren(); show(); }));
work(async function() { var result = await request('/session/'); user = result.user; csrf = result.csrf; show(); if (user) { connect(); await load(); } });
})();What is exercised
The application applies these access and update rules:
- an anonymous socket is refused and the sockets of the owner and of the administrator receive a task event while the socket of another member receives nothing,
- an attachment is accepted only from a user who can see the task, only with a valid CSRF token and only for the allowed types, and files over the limit are refused,
- the owner and the administrator download the file with the right headers while another member gets a 404,
- an administrator deleting the task removes its attachments too.
Connected pages receive task updates through the socket without reloading. Related recipes: WebSocket flags, file storage, uploads and CSRF.