# Taskboard Live: ενημερώσεις σε πραγματικό χρόνο και συνημμένα αρχείων με συνεδρίες

> Επεκτείνετε την αναφορά του Taskboard με τα συνημμένα αρχείων ιδιοκτησίας και ένα αυθεντικό WebSocket που πιέζει τις αλλαγές στους σωστούς ανθρώπους, όλα δοκιμασμένα μέσω HTTP, WebSocket και ενός προγράμματος περιήγησης.

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

Αυτός ο οδηγός πηγαίνει την [αναφορά Taskboard](/en/docs/taskboard-reference/) ένα βήμα παραπέρα. Οι εργασίες δέχονται πλέον **συνημμένα αρχεία**, και ένα **ταυτοποιημένο WebSocket** στέλνει κάθε αλλαγή στους ανθρώπους που επιτρέπεται να τη δουν: τον ιδιοκτήτη της εργασίας και τους διαχειριστές. Η σελίδα ενημερώνεται μόνη της, χωρίς ανανέωση.

Όλα είναι εγγενές Total.js: η ταυτοποίηση συνεδρίας του πρώτου οδηγού, διαδρομές `@upload`, `FILESTORAGE` για τα αρχεία, μια διαδρομή `SOCKET` που απαιτεί συνδεδεμένο χρήστη και ενσωματωμένο NoSQL για τα μεταδεδομένα. Ο διακομιστής αποφασίζει ποιος βλέπει τι: ένα μέλος δεν λαμβάνει ποτέ τα συμβάντα ή τα αρχεία άλλου μέλους, ό,τι κι αν ζητήσει ο browser.

Τα αρχεία παρακάτω είναι πλήρη, οπότε αντιγράψτε τα σε έναν νέο κατάλογο ακριβώς όπως στον πρώτο οδηγό (`demo: true`, οι τρεις τοπικοί χρήστες, `total5@0.0.19`, μόνο loopback). Οι κανόνες demo εκείνου του οδηγού ισχύουν κι εδώ.

## Αρχεία διακομιστή

```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(); } });
})();
```

## Τι ασκείται

Η αίτηση εφαρμόζει τους ακόλουθους κανόνες πρόσβασης και ενημέρωσης:

- ένα ανώνυμο socket απορρίπτεται και τα sockets του ιδιοκτήτη και του διαχειριστή λαμβάνουν συμβάν εργασίας ενώ το socket άλλου μέλους δεν λαμβάνει τίποτα·
- ένα συνημμένο γίνεται δεκτό μόνο από χρήστη που βλέπει την εργασία, μόνο με έγκυρο token CSRF και μόνο για τους επιτρεπτούς τύπους, και τα αρχεία πάνω από το όριο απορρίπτονται·
- ο ιδιοκτήτης και ο διαχειριστής κατεβάζουν το αρχείο με τις σωστές κεφαλίδες ενώ ένα άλλο μέλος παίρνει 404·
- ένας διαχειριστής που διαγράφει την εργασία αφαιρεί και τα συνημμένα της.

Οι συνδεδεμένες σελίδες λαμβάνουν ενημερώσεις εργασιών μέσω του socket χωρίς επανεγκατάσταση. [Φλόγα WebSocket](/en/total5/websocket-flags/), [Αποθήκευση αρχείων](/en/total5/filestorage/), [Κατεβάστε](/en/total5/file-upload/) και [ΚΣΡΦ](/en/total5/csrf-protection/).
