# Taskboard Live : mises à jour en temps réel et fichiers avec sessions

> Étendre la référence de Taskboard avec des annexes de fichier propriétaire et un WebSocket authentifié qui pousse les modifications aux bonnes personnes, toutes testées sur HTTP, WebSocket et un navigateur.

Framework: total5
Product: Total.js 5 (total5@0.0.19)
Language: fr
Content-SHA256: a03a40561de3c52090dd922ba27df88df15d042bc0dc6d8222fdc5c2a690ba65
Snapshot: c0700e321579725c5cf4f5ace1ad7d2866d1b4f93c24a63c0b41b6b7998e976c
Sources: total5-installed@total5@0.0.19
Review: not-recorded

Ce guide pousse la [référence Taskboard](/en/docs/taskboard-reference/) 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

```text config
name : Taskboard Live
demo : true
fs : taskboard-files
```

```js definitions/taskboard.js
var 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;
	});
};
```

```js actions/tasks.js
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();
				});
			});
		});
	}
});
```

```js controllers/taskboard.js
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 });
	});
}
```

```html public/index.html
<!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>
```

```js public/app.js
(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](/en/total5/websocket-flags/), [Fichiers de stockage](/en/total5/filestorage/), [Le chargement](/en/total5/file-upload/) et [Le CSRF](/en/total5/csrf-protection/).
