Taskboard Live : mises à jour en temps réel et fichiers avec sessions
Ce guide pousse la référence Taskboard un cran plus loin. Les tâches acceptent désormais des pièces jointes, et un WebSocket authentifié envoie chaque changement aux personnes autorisées à le voir : le propriétaire de la tâche et les administrateurs. La page se met à jour toute seule, sans rechargement.
Tout est du Total.js natif : l'authentification de session du premier guide, des routes @upload, FILESTORAGE pour les fichiers, une route SOCKET qui exige un utilisateur connecté et du NoSQL embarqué pour les métadonnées. Le serveur décide qui voit quoi : un membre ne reçoit jamais les événements ni les fichiers d'un autre membre, quoi que demande le navigateur.
Les fichiers ci-dessous sont complets ; copiez-les donc dans un nouveau répertoire exactement comme dans le premier guide (demo: true, les trois utilisateurs locaux, total5@0.0.19, loopback uniquement). Les règles de démo de ce guide s'appliquent ici aussi.
Fichiers du serveur
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(); } });
})();Ce qui est testé
L’application s’applique à ces règles d’accès et d’actualisation :
- un socket anonyme est refusé et les sockets du propriétaire et de l'administrateur reçoivent un événement de tâche tandis que le socket d'un autre membre ne reçoit rien ;
- une pièce jointe n'est acceptée que d'un utilisateur qui peut voir la tâche, qu'avec un jeton CSRF valide et que pour les types autorisés, et les fichiers au-delà de la limite sont refusés ;
- le propriétaire et l'administrateur téléchargent le fichier avec les bons en-têtes tandis qu'un autre membre reçoit un 404 ;
- un administrateur qui supprime la tâche supprime aussi ses pièces jointes.
Les pages connectées reçoivent des mises à jour via le socket sans récharger. WebSocket Drapeau, Fichiers de stockage, Le chargement et Le CSRF.