ForgeDocumentatie
nl

Documentatie

Taskboard referentie applicatie met sessies, eigendom en persistente taken

Total.js 5 · total5@0.0.19 applicationreferenceauthcsrfnosqlrolesbrowser Uitgevoerd met Total.js 5

Deze referentie voegt de onderdelen van een bedrijfsapplicatie samen: een anonieme bezoeker opent de interface, logt in, maakt taken aan, markeert ze als gedaan en logt uit. Leden zien alleen hun eigen taken; een beheerder kan elke taak weergeven en verwijderen. De server is eigenaar van elke autorisatiebeslissing, ongeacht welke knoppen de browser toont.

De applicatie gebruikt native routes, acties, schema's, sessie-authenticatie en ingebouwde NoSQL van Total.js. Wachtwoorden gebruiken Nodes scrypt via Total.Crypto. De interface gebruikt gewone browserformulieren en DOM-tekstknopen; er is geen ander frontend-framework of runtime-afhankelijkheid nodig. Directe controllers verzorgen het sessietransport; herbruikbare taakacties gebruiken stabiele Namespace|action-identifiers.

Voer het complete voorbeeld uit

Kopieer de hieronder genoemde bestanden naar een nieuwe map, voeg total5@0.0.19 toe aan package.json en start met require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });. Open localhost:18400. Gebruik een andere map en poort dan Forge. Het exporthulpmiddel van de repository kan dit exacte voorbeeld ook als project uitwerken.

demo: true staat de drie lokale gebruikers alice, bob en admin toe, met de wachtwoorden alice-demo-password, bob-demo-password en admin-demo-password. De demomodus weigert te starten wanneer --release of NODE_ENV=production wordt opgegeven. Houd zijn listener aan loopback gebonden. Het framework zet DEBUG=false met watcher:false, ook wanneer release:false wordt opgegeven; dit voorbeeld gebruikt DEBUG daarom niet als demobeveiliging. Zet voor release demo: false, geef TASKBOARD_SECRET (minstens 32 tekens) en TASKBOARD_ALICE_PASSWORD, TASKBOARD_BOB_PASSWORD, TASKBOARD_ADMIN_PASSWORD (elk minstens 12 tekens) op. De vaste demogegevens mogen niet voor een publieke deployment worden gebruikt.

De gebruikers zijn een bewust kleine vaste map; sessies en taken blijven bewaard in databases/. Een stabiel geheim laat een geldige sessie na herstart opnieuw laden. Uitloggen wacht op de callback van verwijdering uit de sessieopslag voordat het succes teruggeeft. Deel die map niet tussen draaiende instanties, wis hem niet bij deployment en zet hem niet in git. Een publieke deployment heeft ook HTTPS, een vertrouwde reverse proxy, back-ups en een echt beleid voor gebruikersbeheer nodig. Dit is een uitvoerbare referentie, geen volledig product voor identiteitsbeheer.

Serverbestanden

config
name : Taskboard reference
demo : true
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);
});
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|read', {
	user: true,
	params: '*id:UID',
	action: function($) {
		owned(NOSQL('tasks').read().where('id', $.params.id), $.user).callback(function(err, task) {
			if (err) return $.invalid(500);
			task ? $.callback(task) : $.invalid(404);
		});
	}
});

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) {
			err ? $.invalid(500) : $.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);
			count ? $.success() : $.invalid(404);
		});
	}
});

NEWACTION('Tasks|remove', {
	user: true,
	params: '*id:UID',
	action: function($) {
		if ($.user.role !== 'admin') return $.invalid(403);
		NOSQL('tasks').remove().where('id', $.params.id).callback(function(err, count) {
			if (err) return $.invalid(500);
			count ? $.success() : $.invalid(404);
		});
	}
});
controllers/taskboard.js
var attempts = Object.create(null);

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('+GET /tasks/{id}/ --> *Tasks|read');
	ROUTE('+POST /tasks/ <2kb #taskcsrf --> *Tasks|create');
	ROUTE('+PATCH /tasks/{id}/ <2kb #taskcsrf --> *Tasks|update');
	ROUTE('+DELETE /tasks/{id}/ #taskcsrf --> *Tasks|remove');
};

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 · 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{display:flex;align-items:center;gap:10px;padding:14px 0;border-bottom:1px solid #eee}.task-title{flex:1;overflow-wrap:anywhere}.muted{font-size:14px;color:#536078}#message{min-height:24px;color:#9d251e}[hidden]{display:none!important}
</style></head><body><main>
<header><h1>Taskboard</h1><span class="muted">Total.js reference</span></header>
<p>A small workspace with private tasks and a shared administrator view.</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><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 byid = id => document.getElementById(id);
	async function request(path, method, body) {
		var response = await fetch(path, { method: method || 'GET', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf }, body: body === undefined ? undefined : 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 : '';
	}
	async function load() {
		var tasks = await request('/tasks/');
		byid('tasks').replaceChildren();
		byid('empty').hidden = tasks.length > 0;
		tasks.forEach(function(task) {
			var row = document.createElement('li');
			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;
			row.append(checkbox, title);
			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(); }));
				row.append(remove);
			}
			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(); 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) await load(); });
})();

Wat wordt getest en wat blijft over


Bestandsbijlagen en realtime-updates gaan verder in Taskboard Live. Registratie, wachtwoordherstel, meerdere applicatie-instanties en een SQL-driver zijn vervolgvoorbeelden met hun eigen contracten, geen impliciete functies van deze app. Verwante recepten: sessie-authenticatie, CSRF, CRUD-acties en WebSocket.