ForgeDocumentação
pt

Documentação

Taskboard Live: atualizações em tempo real e anexos de arquivo com sessões

Total.js 5 · total5@0.0.19 applicationreferencewebsocketuploadfilestorageauthrealtimebrowser Executada com o Total.js 5

Este guia leva a referência Taskboard um passo mais longe. As tarefas passam a aceitar anexos de ficheiros, e um WebSocket autenticado envia cada alteração às pessoas que a podem ver: o proprietário da tarefa e os administradores. A página atualiza-se sozinha, sem recarregar.

Tudo é Total.js nativo: a autenticação de sessão do primeiro guia, rotas @upload, FILESTORAGE para os ficheiros, uma rota SOCKET que exige um utilizador com sessão iniciada e NoSQL embebido para os metadados. O servidor decide quem vê o quê: um membro nunca recebe os eventos nem os ficheiros de outro membro, peça o que pedir o navegador.

Os ficheiros abaixo estão completos, por isso copie-os para um diretório novo exatamente como no primeiro guia (demo: true, os três utilizadores locais, total5@0.0.19, apenas loopback). As regras de demo desse guia aplicam-se aqui também.

Ficheiros do servidor

config
name : Taskboard Live
demo : true
fs : taskboard-files
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;
	});
};
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();
				});
			});
		});
	}
});
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 });
	});
}
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>
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(); } });
})();

O que é exercitado

A aplicação aplica-se a estas regras de acesso e atualização:

  • um socket anónimo é recusado e os sockets do proprietário e do administrador recebem um evento de tarefa enquanto o socket de outro membro não recebe nada;
  • um anexo só é aceite de um utilizador que pode ver a tarefa, só com um token CSRF válido e só para os tipos permitidos, e os ficheiros acima do limite são recusados;
  • o proprietário e o administrador descarregam o ficheiro com os cabeçalhos certos enquanto outro membro recebe um 404;
  • um administrador que apaga a tarefa remove também os seus anexos.

As páginas conectadas recebem atualizações de tarefas através do socket sem recarregar. Flague de WebSocket, Arquivo de armazenamento, Aumentar e CSRF.