Taskboard Live: tareas y archivos adjuntos en tiempo real
Esta guía lleva la referencia Taskboard un paso más allá. Las tareas ahora aceptan archivos adjuntos, y un WebSocket autenticado envía cada cambio a las personas que pueden verlo: el propietario de la tarea y los administradores. La página se actualiza sola, sin recargar.
Todo es Total.js nativo: la autenticación de sesión de la primera guía, rutas @upload, FILESTORAGE para los archivos, una ruta SOCKET que exige un usuario con sesión iniciada y NoSQL embebido para los metadatos. El servidor decide quién ve qué: un miembro nunca recibe los eventos ni los archivos de otro miembro, pida lo que pida el navegador.
Los archivos de abajo están completos, así que cópialos en un directorio nuevo exactamente como en la primera guía (demo: true, los tres usuarios locales, total5@0.0.19, solo loopback). Las reglas de demo de esa guía valen aquí también.
Archivos del servidor
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(); } });
})();Qué se ejercita
La aplicación aplica estas reglas de acceso y actualización:
- un socket anónimo es rechazado y los sockets del propietario y del administrador reciben un evento de tarea mientras el de otro miembro no recibe nada;
- un adjunto solo se acepta de un usuario que puede ver la tarea, solo con un token CSRF válido y solo para los tipos permitidos, y los archivos que superan el límite se rechazan;
- el propietario y el administrador descargan el archivo con las cabeceras correctas mientras otro miembro recibe un 404;
- un administrador que borra la tarea elimina también sus adjuntos.
Las páginas conectadas reciben actualizaciones de tareas a través del socket sin recargar. Flagos de WebSocket, Archivo de almacenamiento, Aumentar y CSRF.