Taskboard Live: realtime updates en bestand bijlagen met sessies
Deze gids brengt de Taskboard-referentie een stap verder. Taken accepteren nu bestandsbijlagen, en een geauthenticeerde WebSocket stuurt elke wijziging naar de mensen die haar mogen zien: de eigenaar van de taak en de beheerders. De pagina werkt zichzelf bij, zonder verversen.
Alles is native Total.js: de sessie-authenticatie van de eerste gids, @upload-routes, FILESTORAGE voor de bestanden, een SOCKET-route die een ingelogde gebruiker vereist en ingebouwde NoSQL voor de metadata. De server beslist wie wat ziet: een lid ontvangt nooit de gebeurtenissen of bestanden van een ander lid, wat de browser ook vraagt.
De bestanden hieronder zijn compleet, dus kopieer ze naar een nieuwe map precies zoals in de eerste gids (demo: true, de drie lokale gebruikers, total5@0.0.19, alleen loopback). De demoregels van die gids gelden hier ook.
Serverbestanden
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(); } });
})();Wat wordt getest
De aanvraag is van toepassing op deze toegangs- en up-to-date regels:
- een anonieme socket wordt geweigerd en de sockets van de eigenaar en de beheerder een taakgebeurtenis ontvangen terwijl de socket van een ander lid niets ontvangt;
- een bijlage alleen wordt geaccepteerd van een gebruiker die de taak kan zien, alleen met een geldig CSRF-token en alleen voor de toegestane typen, en bestanden boven de limiet worden geweigerd;
- de eigenaar en de beheerder het bestand downloaden met de juiste headers terwijl een ander lid een 404 krijgt;
- een beheerder die de taak verwijdert ook de bijlagen verwijdert.
Verbindende pagina's ontvangen taak-updates via de socket zonder heropladen. WebSocket Vlaggen, bestanden opslaan, opladen en CSRF.