Aplicația de referință pentru tablouri de lucru cu sesiuni, proprietate și sarcini persistente
Această referință reunește piesele unei aplicații de business: un vizitator anonim deschide interfața, se autentifică, creează sarcini, le marchează ca terminate și se deconectează. Membrii văd doar propriile sarcini; un administrator poate lista și șterge orice sarcină. Serverul deține fiecare decizie de autorizare, independent de butoanele pe care le afișează browserul.
Aplicația folosește rute, acțiuni, scheme, autentificare prin sesiune și NoSQL încorporat native din Total.js. Parolele folosesc scrypt din Node prin Total.Crypto. Interfața folosește formulare obișnuite de browser și noduri de text DOM; nu este nevoie de alt framework frontend sau dependență la rulare. Controlerele directe gestionează transportul sesiunii; acțiunile reutilizabile pentru sarcini folosesc identificatori stabili Namespace|action.
Rulează exemplul complet
Copiază fișierele numite mai jos într-un director nou, adaugă total5@0.0.19 în package.json și pornește cu require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });. Deschide localhost:18400. Folosește un director și un port diferite de cele ale Forge. Instrumentul de export al depozitului poate materializa acest exemplu exact și ca proiect.
demo: true permite cei trei utilizatori locali alice, bob și admin, cu parolele alice-demo-password, bob-demo-password și admin-demo-password. Modul demo refuză să pornească atunci când se furnizează --release sau NODE_ENV=production. Ține listener-ul legat de loopback. Framework-ul setează DEBUG=false cu watcher:false chiar și când se furnizează release:false; de aceea acest exemplu nu folosește DEBUG ca gardă pentru demo. Pentru release, setează demo: false, furnizează TASKBOARD_SECRET (cel puțin 32 de caractere) și TASKBOARD_ALICE_PASSWORD, TASKBOARD_BOB_PASSWORD, TASKBOARD_ADMIN_PASSWORD (fiecare de cel puțin 12 caractere). Credențialele fixe ale demo-ului nu trebuie folosite într-un deployment public.
Utilizatorii sunt un director fix, mic în mod deliberat; sesiunile și sarcinile persistă în databases/. Un secret stabil permite reîncărcarea unei sesiuni valide după repornire. Deconectarea așteaptă callback-ul de eliminare din depozitul de sesiuni înainte de a returna succes. Nu partaja acel director între instanțe care rulează, nu-l șterge la deployment și nu-l pune în git. Un deployment public are nevoie și de HTTPS, un reverse proxy de încredere, backup-uri și o politică reală de creare a utilizatorilor. Aceasta este o referință executabilă, nu un produs complet de gestionare a identităților.
Fișierele serverului
name : Taskboard reference
demo : truevar 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);
});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);
});
}
});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 });
});
}<!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>(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(); });
})();Ce este testat și ce rămâne
Atașamentele de fișiere și actualizările în timp real continuă în Taskboard Live. Înregistrarea, resetarea parolei, mai multe instanțe ale aplicației și un driver SQL sunt exemple ulterioare cu propriile contracte, nu funcții implicite ale acestei aplicații. Rețete conexe: autentificare prin sesiune, CSRF, acțiuni CRUD și WebSocket.