ForgeDokumentation
de

Dokumentation

Referenzanwendung mit Sitzungen, Eigentum und dauerhaften Aufgaben

Total.js 5 · total5@0.0.19 applicationreferenceauthcsrfnosqlrolesbrowser Mit Total.js 5 ausgeführt

Diese Referenz verbindet die Teile einer Geschäftsanwendung: Ein anonymer Besucher öffnet die Oberfläche, meldet sich an, legt Aufgaben an, markiert sie als erledigt und meldet sich ab. Mitglieder sehen nur ihre eigenen Aufgaben; ein Administrator kann jede Aufgabe auflisten und löschen. Der Server besitzt jede Autorisierungsentscheidung, unabhängig davon, welche Schaltflächen der Browser anzeigt.

Die Anwendung nutzt native Total.js-Routen, Aktionen, Schemas, Sitzungs-Authentifizierung und eingebettetes NoSQL. Passwörter verwenden Nodes scrypt über Total.Crypto. Die Oberfläche nutzt gewöhnliche Browser-Formulare und DOM-Textknoten; kein anderes Frontend-Framework und keine Laufzeitabhängigkeit ist nötig. Direkte Controller übernehmen den Sitzungstransport; wiederverwendbare Aufgaben-Aktionen verwenden stabile Namespace|action-Bezeichner.

Das komplette Beispiel ausführen

Kopiere die unten genannten Dateien in ein neues Verzeichnis, füge total5@0.0.19 zur package.json hinzu und starte mit require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });. Öffne localhost:18400. Verwende ein anderes Verzeichnis und einen anderen Port als Forge. Das Export-Werkzeug des Repositorys kann genau dieses Beispiel auch als Projekt erzeugen.

demo: true erlaubt die drei lokalen Benutzer alice, bob und admin mit den Passwörtern alice-demo-password, bob-demo-password und admin-demo-password. Der Demo-Modus verweigert den Start, wenn --release oder NODE_ENV=production übergeben wird. Halte seinen Listener an Loopback gebunden. Das Framework setzt DEBUG=false mit watcher:false, auch wenn release:false übergeben wird; dieses Beispiel verwendet DEBUG daher nicht als Demo-Wächter. Für Release setze demo: false, gib TASKBOARD_SECRET (mindestens 32 Zeichen) sowie TASKBOARD_ALICE_PASSWORD, TASKBOARD_BOB_PASSWORD, TASKBOARD_ADMIN_PASSWORD (jeweils mindestens 12 Zeichen) an. Die festen Demo-Zugangsdaten dürfen nicht in einem öffentlichen Deployment verwendet werden.

Die Benutzer sind ein bewusst kleines festes Verzeichnis; Sitzungen und Aufgaben bleiben in databases/ erhalten. Ein stabiles Geheimnis lässt eine gültige Sitzung nach einem Neustart wieder laden. Das Abmelden wartet auf den Callback der Entfernung aus dem Sitzungsspeicher, bevor es Erfolg zurückgibt. Teile dieses Verzeichnis nicht zwischen laufenden Instanzen, lösche es nicht beim Deployment und lege es nicht in git ab. Ein öffentliches Deployment braucht außerdem HTTPS, einen vertrauenswürdigen Reverse-Proxy, Backups und eine echte Richtlinie zur Benutzerbereitstellung. Dies ist eine ausführbare Referenz, kein vollständiges Produkt zur Identitätsverwaltung.

Server-Dateien

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

Was geprüft wird und was offen bleibt


Dateianhänge und Echtzeit-Aktualisierungen gehen in Taskboard Live weiter. Registrierung, Passwort-Zurücksetzen, mehrere Anwendungsinstanzen und ein SQL-Treiber sind Folgebeispiele mit eigenen Verträgen, keine impliziten Funktionen dieser Anwendung. Verwandte Rezepte: Sitzungs-Authentifizierung, CSRF, CRUD-Aktionen und WebSocket.