Taskboard Live: Realtime-Updates und Dateien mit Sitzungen
Diese Anleitung führt die Taskboard-Referenz einen Schritt weiter. Aufgaben akzeptieren jetzt Dateianhänge, und ein authentifizierter WebSocket schickt jede Änderung an die Personen, die sie sehen dürfen: den Besitzer der Aufgabe und die Administratoren. Die Seite aktualisiert sich von selbst, ohne Neuladen.
Alles ist natives Total.js: die Sitzungs-Authentifizierung der ersten Anleitung, @upload-Routen, FILESTORAGE für die Dateien, eine SOCKET-Route, die einen angemeldeten Benutzer verlangt, und eingebettetes NoSQL für die Metadaten. Der Server entscheidet, wer was sieht: Ein Mitglied erhält nie die Ereignisse oder Dateien eines anderen Mitglieds, was auch immer der Browser anfragt.
Die Dateien unten sind vollständig, kopiere sie also genau wie in der ersten Anleitung in ein neues Verzeichnis (demo: true, die drei lokalen Benutzer, total5@0.0.19, nur Loopback). Die Demo-Regeln jener Anleitung gelten auch hier.
Server-Dateien
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(); } });
})();Was geprüft wird
Die Anwendung gilt für diese Zugriffs- und Aktualisierungsregeln:
- ein anonymer Socket abgelehnt wird und die Sockets von Besitzer und Administrator ein Aufgaben-Ereignis erhalten, während der Socket eines anderen Mitglieds nichts erhält;
- ein Anhang nur von einem Benutzer akzeptiert wird, der die Aufgabe sehen kann, nur mit gültigem CSRF-Token und nur für die erlaubten Typen, und Dateien über dem Limit abgelehnt werden;
- Besitzer und Administrator die Datei mit den richtigen Headern herunterladen, während ein anderes Mitglied ein 404 erhält;
- ein Administrator, der die Aufgabe löscht, auch ihre Anhänge entfernt.
Die verbundenen Seiten erhalten Aufgaben-Updates über den Socket ohne Wiederladung. WebSocket Flagge, Datei Lagerung, Laden und CSRF.