Εφαρμογή αναφοράς εργασίας με συνεδρίες, ιδιοκτησία και επίμονες εργασίες
Αυτή η αναφορά ενώνει τα κομμάτια μιας επιχειρηματικής εφαρμογής: ένας ανώνυμος επισκέπτης ανοίγει τη διεπαφή, συνδέεται, δημιουργεί εργασίες, τις σημειώνει ως ολοκληρωμένες και αποσυνδέεται. Τα μέλη βλέπουν μόνο τις δικές τους εργασίες· ένας διαχειριστής μπορεί να απαριθμήσει και να διαγράψει οποιαδήποτε εργασία. Ο διακομιστής κατέχει κάθε απόφαση εξουσιοδότησης, ανεξάρτητα από τα κουμπιά που εμφανίζει ο browser.
Η εφαρμογή χρησιμοποιεί εγγενείς διαδρομές, ενέργειες, σχήματα, ταυτοποίηση συνεδρίας και ενσωματωμένο NoSQL του Total.js. Οι κωδικοί χρησιμοποιούν το scrypt του Node μέσω Total.Crypto. Η διεπαφή χρησιμοποιεί συνηθισμένες φόρμες browser και κόμβους κειμένου DOM· δεν απαιτείται άλλο frontend framework ή εξάρτηση χρόνου εκτέλεσης. Οι άμεσοι ελεγκτές χειρίζονται τη μεταφορά της συνεδρίας· οι επαναχρησιμοποιήσιμες ενέργειες εργασιών χρησιμοποιούν σταθερά αναγνωριστικά Namespace|action.
Εκτελέστε το πλήρες παράδειγμα
Αντιγράψτε τα αρχεία που αναφέρονται παρακάτω σε νέο κατάλογο, προσθέστε total5@0.0.19 στο package.json και ξεκινήστε με require('total5'); Total.run({ ip: '127.0.0.1', port: 18400, release: false, watcher: false });. Ανοίξτε localhost:18400. Χρησιμοποιήστε ξεχωριστό κατάλογο και θύρα από το Forge. Το εργαλείο εξαγωγής του αποθετηρίου μπορεί επίσης να υλοποιήσει αυτό ακριβώς το παράδειγμα ως project.
Το demo: true επιτρέπει τους τρεις τοπικούς χρήστες alice, bob και admin, με κωδικούς alice-demo-password, bob-demo-password και admin-demo-password. Η λειτουργία demo αρνείται να ξεκινήσει όταν δίνεται --release ή NODE_ENV=production. Κρατήστε τον listener της δεσμευμένο στο loopback. Το framework ορίζει DEBUG=false με watcher:false ακόμη και όταν δίνεται release:false· γι᾽ αυτό αυτό το παράδειγμα δεν χρησιμοποιεί το DEBUG ως φρουρό του demo. Για release, ορίστε demo: false, δώστε TASKBOARD_SECRET (τουλάχιστον 32 χαρακτήρες) και TASKBOARD_ALICE_PASSWORD, TASKBOARD_BOB_PASSWORD, TASKBOARD_ADMIN_PASSWORD (το καθένα τουλάχιστον 12 χαρακτήρες). Τα σταθερά διαπιστευτήρια του demo δεν πρέπει να χρησιμοποιούνται σε δημόσια ανάπτυξη.
Οι χρήστες είναι ένας σκόπιμα μικρός σταθερός κατάλογος· οι συνεδρίες και οι εργασίες διατηρούνται στο databases/. Ένα σταθερό μυστικό επιτρέπει σε μια έγκυρη συνεδρία να φορτωθεί ξανά μετά από επανεκκίνηση. Η αποσύνδεση περιμένει το callback αφαίρεσης από το αποθηκευτικό σύστημα συνεδριών πριν επιστρέψει επιτυχία. Μην μοιράζεστε αυτόν τον κατάλογο μεταξύ εκτελούμενων στιγμιοτύπων, μην τον διαγράφετε κατά την ανάπτυξη και μην τον βάζετε στο git. Μια δημόσια ανάπτυξη χρειάζεται επίσης HTTPS, αξιόπιστο reverse proxy, αντίγραφα ασφαλείας και πραγματική πολιτική παροχής χρηστών. Αυτή είναι εκτελέσιμη αναφορά, όχι πλήρες προϊόν διαχείρισης ταυτοτήτων.
Αρχεία διακομιστή
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(); });
})();Τι ασκείται και τι μένει
Τα συνημμένα αρχεία και οι ενημερώσεις πραγματικού χρόνου συνεχίζονται στο Taskboard Live. Η εγγραφή, η επαναφορά κωδικού, τα πολλαπλά στιγμιότυπα εφαρμογής και ένας driver SQL είναι επόμενα παραδείγματα με δικά τους συμβόλαια, όχι υπονοούμενες λειτουργίες αυτής της εφαρμογής. Σχετικές συνταγές: ταυτοποίηση συνεδρίας, CSRF, ενέργειες CRUD και WebSocket.