735 lines
27 KiB
JavaScript
735 lines
27 KiB
JavaScript
// ── Fake terminal: click the Environment card's icon ──────────
|
|
(function () {
|
|
const glyph = document.querySelector('.skill-card-icon .fa-terminal');
|
|
if (!glyph) return;
|
|
const icon = glyph.closest('.skill-card-icon');
|
|
|
|
const HOST = 'gade.gg';
|
|
const KERNEL = '6.6-goat';
|
|
const T0 = performance.now();
|
|
|
|
// Current user: su can change it (root's password leaked in auth.log),
|
|
// exit pops back. Root passes every permission check and may write.
|
|
const USERS = {
|
|
root: ['root'],
|
|
nikolaj: ['home', 'nikolaj'],
|
|
guest: ['home', 'guest'],
|
|
pepsi: ['home', 'pepsi'],
|
|
};
|
|
const PASSWORDS = { root: 'Pepsi2022' };
|
|
let user = 'guest';
|
|
let home = USERS.guest;
|
|
const userStack = [];
|
|
let pendingSu = null; // user being su'd to while the password prompt is up
|
|
|
|
// Filesystem: dirs are { children }, files are strings — or functions
|
|
// returning a string, for live content (.bash_history). locked dirs
|
|
// deny everyone but root and their owner. Only root may write.
|
|
const dir = (children) => ({ children });
|
|
const locked = (owner, children) => ({ children, locked: owner });
|
|
const DENIED = { denied: true }; // a path tried to pass through a locked dir
|
|
const isFile = (n) => typeof n === 'string' || typeof n === 'function';
|
|
const fileBody = (n) => (typeof n === 'function' ? n() : n);
|
|
const allowed = (n) => !n.locked || user === 'root' || user === n.locked;
|
|
const GOAT = ` %%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%
|
|
%%%% %%%%%%%%%%%
|
|
%%% %%%%%%%%%%%%%%%
|
|
%% %%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%
|
|
%%%%%%%%%%%%%%%
|
|
`;
|
|
const BIN = '\u007fELF��������';
|
|
const FS = dir({
|
|
bin: dir({ cat: BIN, echo: BIN, gsh: BIN, ls: BIN, nano: BIN, rm: BIN, sh: BIN }),
|
|
etc: dir({
|
|
hostname: 'gade.gg\n',
|
|
hosts:
|
|
'127.0.0.1 localhost\n' +
|
|
'10.0.0.1 bulbasaur\n' +
|
|
'10.0.0.6 charizard\n' +
|
|
'10.0.0.29 nidoran-f\n' +
|
|
'10.0.0.32 nidoran-m\n' +
|
|
'10.0.0.58 growlithe\n' +
|
|
'10.0.0.63 abra\n' +
|
|
'10.0.0.64 kadabra\n' +
|
|
'10.0.0.133 eevee\n' +
|
|
'10.0.0.150 mewtwo\n' +
|
|
'10.0.0.151 mew\n',
|
|
motd: "Welcome to gade.gg.\nType 'help' for commands.\n",
|
|
passwd:
|
|
'root:x:0:0:root:/root:/bin/sh\n' +
|
|
'nikolaj:x:1000:1000:Nikolaj Gade:/home/nikolaj:/bin/fish\n' +
|
|
'guest:x:1001:1001:guest:/home/guest:/bin/gsh\n' +
|
|
'pepsi:x:1002:1002:Pepsi Max Lime Gade:/home/pepsi:/usr/bin/meow\n',
|
|
}),
|
|
home: dir({
|
|
guest: dir({
|
|
'readme.txt': 'Nothing here yet. Have a look around.\n',
|
|
'.bash_history': () => (history.length ? history.join('\n') + '\n' : ''),
|
|
'.bashrc': "# ~/.bashrc\nPS1='\\u@\\h:\\w\\$ '\nalias sl=ls\n",
|
|
}),
|
|
nikolaj: locked('nikolaj', {
|
|
'array-props.pdf': '%PDF-1.7\n%����\n',
|
|
}),
|
|
pepsi: dir({
|
|
'.plan':
|
|
'nap.\n' +
|
|
'nap.\n' +
|
|
'knock something off the table.\n' +
|
|
'nap.\n',
|
|
}),
|
|
}),
|
|
proc: dir({
|
|
uptime: () => {
|
|
const s = (performance.now() - T0) / 1000;
|
|
return s.toFixed(2) + ' ' + (s * 4).toFixed(2) + '\n';
|
|
},
|
|
version:
|
|
'Linux version ' + KERNEL + ' (rats@gade.gg) (gcc (GCC) 14.2.1) #1 SMP PREEMPT\n',
|
|
}),
|
|
root: locked('root', {
|
|
goat: GOAT,
|
|
'.bash_history': 'rm -rf /\n',
|
|
}),
|
|
usr: dir({
|
|
bin: dir({ meow: BIN }),
|
|
share: dir({
|
|
centvrion: dir({ 'salve.cent': '// Hello, world\nDIC("SALVE MVNDE")\n' }),
|
|
}),
|
|
}),
|
|
var: dir({
|
|
log: dir({
|
|
'auth.log':
|
|
'Aug 9 03:12:44 gade sshd[812]: Failed password for root from 10.0.0.150 port 51434 ssh2\n' +
|
|
'Aug 9 03:12:51 gade sshd[815]: Failed password for invalid user Pepsi2022 from 10.0.0.150 port 51438 ssh2\n' +
|
|
'Aug 9 03:13:02 gade sshd[819]: Accepted password for root from 10.0.0.150 port 51442 ssh2\n',
|
|
}),
|
|
}),
|
|
});
|
|
|
|
let cwd = [...USERS.guest];
|
|
|
|
function resolvePath(path) {
|
|
if (path === '~') path = '/' + home.join('/');
|
|
else if (path.startsWith('~/')) path = '/' + home.join('/') + path.slice(1);
|
|
const segs = path.startsWith('/') ? [] : [...cwd];
|
|
for (const part of path.split('/')) {
|
|
if (part === '' || part === '.') continue;
|
|
if (part === '..') segs.pop();
|
|
else segs.push(part);
|
|
}
|
|
return segs;
|
|
}
|
|
|
|
function nodeAt(segs) {
|
|
let node = FS;
|
|
for (const s of segs) {
|
|
if (!allowed(node)) return DENIED;
|
|
if (!node.children || !Object.prototype.hasOwnProperty.call(node.children, s)) return null;
|
|
node = node.children[s];
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function displayPath(segs) {
|
|
const p = '/' + segs.join('/');
|
|
const h = '/' + home.join('/');
|
|
if (p === h) return '~';
|
|
if (p.startsWith(h + '/')) return '~' + p.slice(h.length);
|
|
return p;
|
|
}
|
|
|
|
const promptText = () =>
|
|
user + '@' + HOST + ':' + displayPath(cwd) + (user === 'root' ? '#' : '$');
|
|
|
|
let dialog = null;
|
|
let screen = null;
|
|
let out = null;
|
|
let input = null;
|
|
let promptEl = null;
|
|
let termTitle = null;
|
|
|
|
function becomeUser(u) {
|
|
user = u;
|
|
home = USERS[u];
|
|
// The quiet flag a future achievements system reads
|
|
if (u === 'root') {
|
|
try { localStorage.setItem('termRoot', '1'); } catch (e) {}
|
|
if (window.achv) window.achv.unlock('hacker');
|
|
}
|
|
promptEl.textContent = promptText();
|
|
termTitle.textContent = u + '@' + HOST;
|
|
}
|
|
|
|
// Real history: ↑ recalls it, ~/.bash_history reads it, localStorage
|
|
// keeps it. Seeded so a first visit finds a previous guest's session.
|
|
const HIST_SEED = ['ls', 'cd /', 'ls', 'cat etc/motd', 'cd'];
|
|
const HIST_MAX = 200;
|
|
let history = HIST_SEED;
|
|
try { history = JSON.parse(localStorage.getItem('termHistory')) || HIST_SEED; } catch (e) {}
|
|
if (!Array.isArray(history)) history = HIST_SEED;
|
|
history = history.slice(-HIST_MAX).map(String);
|
|
let hIdx = history.length;
|
|
let draft = '';
|
|
function saveHistory() {
|
|
try { localStorage.setItem('termHistory', JSON.stringify(history)); } catch (e) {}
|
|
}
|
|
|
|
// parts: [{ text, cls? }] — one scrollback line
|
|
function line(parts) {
|
|
const el = document.createElement('div');
|
|
el.className = 'term-line';
|
|
for (const part of parts) {
|
|
if (part.cls) {
|
|
const span = document.createElement('span');
|
|
span.className = part.cls;
|
|
span.textContent = part.text;
|
|
el.appendChild(span);
|
|
} else {
|
|
el.appendChild(document.createTextNode(part.text));
|
|
}
|
|
}
|
|
out.appendChild(el);
|
|
}
|
|
const print = (text) => line([{ text }]);
|
|
|
|
// hidden commands work but stay out of help and tab completion —
|
|
// finding them is the point
|
|
const ALIASES = { sl: 'ls' }; // honoring ~/.bashrc
|
|
const COMMANDS = {
|
|
cat: { usage: 'cat <file>', desc: 'print a file', run(args) {
|
|
for (const t of args) {
|
|
const node = nodeAt(resolvePath(t));
|
|
if (node === null) print('cat: ' + t + ': No such file or directory');
|
|
else if (node === DENIED) print('cat: ' + t + ': Permission denied');
|
|
else if (isFile(node)) {
|
|
const body = fileBody(node).replace(/\n$/, '');
|
|
if (body) print(body);
|
|
} else print('cat: ' + t + ': Is a directory');
|
|
}
|
|
} },
|
|
cd: { usage: 'cd [dir]', desc: 'change directory', run(args) {
|
|
const target = args[0] || '~';
|
|
const segs = resolvePath(target);
|
|
const node = nodeAt(segs);
|
|
if (node === null) print('cd: no such file or directory: ' + target);
|
|
else if (node === DENIED || !allowed(node)) print('cd: permission denied: ' + target);
|
|
else if (isFile(node)) print('cd: not a directory: ' + target);
|
|
else {
|
|
cwd = segs;
|
|
promptEl.textContent = promptText();
|
|
}
|
|
} },
|
|
clear: { usage: 'clear', desc: 'clear the screen', run() { out.textContent = ''; } },
|
|
echo: { usage: 'echo [text]', desc: 'print text', run(args) { print(args.join(' ')); } },
|
|
exit: { usage: 'exit', desc: 'close the terminal', run() {
|
|
if (userStack.length) becomeUser(userStack.pop());
|
|
else dialog.close();
|
|
} },
|
|
help: { usage: 'help', desc: 'list commands', run() {
|
|
const names = Object.keys(COMMANDS).filter((n) => !COMMANDS[n].hidden).sort();
|
|
const pad = Math.max(...names.map((n) => COMMANDS[n].usage.length)) + 2;
|
|
names.forEach((n) => print(' ' + COMMANDS[n].usage.padEnd(pad) + COMMANDS[n].desc));
|
|
} },
|
|
history: { hidden: true, usage: 'history', desc: '', run() {
|
|
history.forEach((h, i) => print(String(i + 1).padStart(5) + ' ' + h));
|
|
} },
|
|
ls: { usage: 'ls [-a] [dir]', desc: 'list directory contents', run(args) {
|
|
const all = args.some((a) => /^-[a-zA-Z]*a/.test(a));
|
|
const paths = args.filter((a) => !a.startsWith('-'));
|
|
const target = paths[0] || '.';
|
|
const node = nodeAt(resolvePath(target));
|
|
if (node === null) { print("ls: cannot access '" + target + "': No such file or directory"); return; }
|
|
if (node === DENIED || !allowed(node)) { print("ls: cannot open directory '" + target + "': Permission denied"); return; }
|
|
if (isFile(node)) { print(target); return; }
|
|
let names = Object.keys(node.children).sort();
|
|
names = all ? ['.', '..', ...names] : names.filter((n) => !n.startsWith('.'));
|
|
if (!names.length) return;
|
|
const parts = [];
|
|
names.forEach((n, i) => {
|
|
if (i) parts.push({ text: ' ' });
|
|
const child = n === '.' || n === '..' ? FS : node.children[n];
|
|
parts.push(isFile(child) ? { text: n } : { text: n, cls: 'term-dir' });
|
|
});
|
|
line(parts);
|
|
} },
|
|
meow: { hidden: true, usage: 'meow', desc: '', run() { print('meow'); } },
|
|
nano: { usage: 'nano [file]', desc: 'edit a file', run(args) {
|
|
const target = args[0];
|
|
let name = 'New Buffer';
|
|
let content = '';
|
|
let segs = null;
|
|
if (target) {
|
|
segs = resolvePath(target);
|
|
const node = nodeAt(segs);
|
|
if (node === DENIED) { print('nano: ' + target + ': Permission denied'); return; }
|
|
if (node !== null && !isFile(node)) { print('nano: ' + target + ': Is a directory'); return; }
|
|
if (node !== null) content = fileBody(node);
|
|
name = segs[segs.length - 1] || target;
|
|
}
|
|
openNano(name, content, segs);
|
|
} },
|
|
pwd: { usage: 'pwd', desc: 'print working directory', run() { print('/' + cwd.join('/')); } },
|
|
rm: { usage: 'rm [-rf] <file>', desc: 'remove files', run(args) {
|
|
const recursive = args.some((a) => /^-[a-zA-Z]*r/i.test(a) || a === '--recursive');
|
|
const targets = args.filter((a) => !a.startsWith('-'));
|
|
if (!targets.length) { print('rm: missing operand'); return; }
|
|
for (const t of targets) {
|
|
const segs = resolvePath(t);
|
|
// The one write the filesystem allows: rm -rf / is the real
|
|
// delete-website flow, confirmation dialog and all
|
|
if (recursive && segs.length === 0) {
|
|
const dlg = document.getElementById('deleteDialog');
|
|
if (dlg) {
|
|
dlg.showModal();
|
|
// Chromium: once a nested modal has closed, the input under it
|
|
// still gets key events but no longer inserts text — only a
|
|
// fresh input element types again
|
|
dlg.addEventListener('close', () => {
|
|
const fresh = input.cloneNode();
|
|
input.replaceWith(fresh);
|
|
input = fresh;
|
|
wireInput(fresh);
|
|
fresh.focus();
|
|
}, { once: true });
|
|
return;
|
|
}
|
|
}
|
|
const node = nodeAt(segs);
|
|
if (node === null) print("rm: cannot remove '" + t + "': No such file or directory");
|
|
else if (node === DENIED) print("rm: cannot remove '" + t + "': Permission denied");
|
|
else if (!isFile(node) && !recursive) print("rm: cannot remove '" + t + "': Is a directory");
|
|
else if (user !== 'root') print("rm: cannot remove '" + t + "': Permission denied");
|
|
else {
|
|
const parent = nodeAt(segs.slice(0, -1));
|
|
delete parent.children[segs[segs.length - 1]];
|
|
}
|
|
}
|
|
} },
|
|
roll: { usage: 'roll [NdS+M]', desc: 'roll dice', run(args) {
|
|
const spec = (args[0] || 'd20').toLowerCase();
|
|
const m = /^(\d*)d(\d+)([+-]\d+)?$/.exec(spec);
|
|
if (!m) { print('roll: usage: roll [NdS+M], e.g. roll 2d6+1'); return; }
|
|
const n = Math.min(parseInt(m[1] || '1', 10) || 1, 100);
|
|
const sides = parseInt(m[2], 10);
|
|
const mod = parseInt(m[3] || '0', 10);
|
|
const rolls = [];
|
|
for (let i = 0; i < n; i++) rolls.push(1 + Math.floor(Math.random() * sides));
|
|
const total = rolls.reduce((a, r) => a + r, 0) + mod;
|
|
if (n === 1 && !mod) { print(spec + ': ' + total); return; }
|
|
let breakdown = rolls.join(' + ');
|
|
if (mod) breakdown += mod > 0 ? ' + ' + mod : ' - ' + -mod;
|
|
print(spec + ': ' + breakdown + ' = ' + total);
|
|
} },
|
|
su: { hidden: true, usage: 'su [user]', desc: '', run(args) {
|
|
const target = args.filter((a) => !a.startsWith('-'))[0] || 'root';
|
|
if (!(target in USERS)) { print('su: user ' + target + ' does not exist'); return; }
|
|
if (user === 'root') {
|
|
userStack.push(user);
|
|
becomeUser(target);
|
|
return;
|
|
}
|
|
pendingSu = target;
|
|
promptEl.textContent = 'Password:';
|
|
input.type = 'password';
|
|
} },
|
|
sudo: { hidden: true, usage: 'sudo', desc: '', run(args) {
|
|
if (user !== 'root') {
|
|
print(user + ' is not in the sudoers file. This incident will be reported.');
|
|
return;
|
|
}
|
|
if (!args.length) return;
|
|
const cmd = COMMANDS[ALIASES[args[0]] || args[0]];
|
|
if (cmd) cmd.run(args.slice(1));
|
|
else print('sudo: ' + args[0] + ': command not found');
|
|
} },
|
|
uname: { hidden: true, usage: 'uname', desc: '', run(args) {
|
|
if (args.includes('-a')) print('Linux ' + HOST + ' ' + KERNEL + ' #1 SMP PREEMPT x86_64 GNU/Linux');
|
|
else if (args.includes('-r')) print(KERNEL);
|
|
else print('Linux');
|
|
} },
|
|
whoami: { usage: 'whoami', desc: 'print user name', run() { print(user); } },
|
|
};
|
|
|
|
function runLine(raw) {
|
|
line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: raw }]);
|
|
const trimmed = raw.trim();
|
|
if (trimmed) {
|
|
if (trimmed !== history[history.length - 1]) {
|
|
history.push(trimmed);
|
|
if (history.length > HIST_MAX) history.shift();
|
|
saveHistory();
|
|
}
|
|
const tokens = trimmed.split(/\s+/);
|
|
const cmd = COMMANDS[ALIASES[tokens[0]] || tokens[0]];
|
|
if (cmd) cmd.run(tokens.slice(1));
|
|
else print('gsh: ' + tokens[0] + ': command not found');
|
|
}
|
|
hIdx = history.length;
|
|
draft = '';
|
|
screen.scrollTop = screen.scrollHeight;
|
|
}
|
|
|
|
// Tab: complete command names (first word) or paths (arguments) to the
|
|
// longest shared prefix; a stuck tab with several matches lists them
|
|
function complete() {
|
|
const v = input.value;
|
|
const token = /(\S*)$/.exec(v)[1];
|
|
const before = v.slice(0, v.length - token.length);
|
|
let base = '';
|
|
let candidates;
|
|
if (before.trim() === '') {
|
|
candidates = Object.keys(COMMANDS)
|
|
.filter((c) => !COMMANDS[c].hidden && c.startsWith(token))
|
|
.map((c) => c + ' ');
|
|
} else {
|
|
const slash = token.lastIndexOf('/');
|
|
base = slash >= 0 ? token.slice(0, slash + 1) : '';
|
|
const prefix = token.slice(slash + 1);
|
|
const node = nodeAt(resolvePath(base || '.'));
|
|
if (!node || node === DENIED || !allowed(node) || isFile(node)) return;
|
|
candidates = Object.keys(node.children)
|
|
.filter((n) => n.startsWith(prefix) && (prefix.startsWith('.') || !n.startsWith('.')))
|
|
.map((n) => n + (isFile(node.children[n]) ? ' ' : '/'));
|
|
}
|
|
if (!candidates.length) return;
|
|
let lcp = candidates[0];
|
|
for (const c of candidates) {
|
|
while (!c.startsWith(lcp)) lcp = lcp.slice(0, -1);
|
|
}
|
|
if (candidates.length === 1) {
|
|
input.value = before + base + candidates[0];
|
|
} else if (lcp && base + lcp !== token) {
|
|
input.value = before + base + lcp;
|
|
} else {
|
|
line(candidates.map((c, i) => ({ text: (i ? ' ' : '') + c.trimEnd() })));
|
|
screen.scrollTop = screen.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// ── nano: the one installed editor. Editing works; saving is another
|
|
// matter (the filesystem is read-only, and it says so like nano would)
|
|
let nano = null;
|
|
let nanoTitleName = null;
|
|
let nanoTitleMod = null;
|
|
let nanoEdit = null;
|
|
let nanoStatus = null;
|
|
let nanoKeys = null;
|
|
let nanoName = '';
|
|
let nanoSegs = null;
|
|
let nanoOriginal = '';
|
|
let nanoPrompting = false;
|
|
const NANO_EDIT_KEYS = [['^X', 'Exit'], ['^O', 'Write Out']];
|
|
|
|
function setNanoKeys(pairs) {
|
|
nanoKeys.textContent = '';
|
|
pairs.forEach(([key, label]) => {
|
|
const k = document.createElement('span');
|
|
k.className = 'nano-key';
|
|
k.textContent = key;
|
|
nanoKeys.appendChild(k);
|
|
nanoKeys.appendChild(document.createTextNode(' ' + label + ' '));
|
|
});
|
|
}
|
|
|
|
function setNanoStatus(msg) {
|
|
nanoStatus.textContent = '';
|
|
if (msg) {
|
|
const s = document.createElement('span');
|
|
s.textContent = msg;
|
|
nanoStatus.appendChild(s);
|
|
}
|
|
}
|
|
|
|
function updateNanoTitle() {
|
|
nanoTitleName.textContent = nanoName;
|
|
nanoTitleMod.textContent = nanoEdit.value === nanoOriginal ? '' : 'Modified';
|
|
}
|
|
|
|
function buildNano() {
|
|
nano = document.createElement('div');
|
|
nano.className = 'nano';
|
|
const title = document.createElement('div');
|
|
title.className = 'nano-bar nano-title';
|
|
const version = document.createElement('span');
|
|
version.textContent = 'GNU nano 8.1';
|
|
nanoTitleName = document.createElement('span');
|
|
nanoTitleMod = document.createElement('span');
|
|
title.append(version, nanoTitleName, nanoTitleMod);
|
|
nanoEdit = document.createElement('textarea');
|
|
nanoEdit.className = 'nano-edit';
|
|
nanoEdit.spellcheck = false;
|
|
nanoEdit.setAttribute('aria-label', 'Editor');
|
|
nanoStatus = document.createElement('div');
|
|
nanoStatus.className = 'nano-status';
|
|
nanoKeys = document.createElement('div');
|
|
nanoKeys.className = 'nano-keys';
|
|
nano.append(title, nanoEdit, nanoStatus, nanoKeys);
|
|
nanoEdit.addEventListener('keydown', nanoKeydown);
|
|
nanoEdit.addEventListener('input', updateNanoTitle);
|
|
}
|
|
|
|
function openNano(name, content, segs) {
|
|
if (!nano) buildNano();
|
|
nanoName = name;
|
|
nanoSegs = segs;
|
|
nanoOriginal = content;
|
|
nanoEdit.value = content;
|
|
nanoPrompting = false;
|
|
setNanoStatus('');
|
|
setNanoKeys(NANO_EDIT_KEYS);
|
|
updateNanoTitle();
|
|
screen.classList.add('nano-open');
|
|
screen.appendChild(nano);
|
|
nanoEdit.focus();
|
|
nanoEdit.setSelectionRange(0, 0);
|
|
}
|
|
|
|
function closeNano() {
|
|
nanoPrompting = false;
|
|
screen.classList.remove('nano-open');
|
|
nano.remove();
|
|
input.focus();
|
|
screen.scrollTop = screen.scrollHeight;
|
|
}
|
|
|
|
function nanoAskExit() {
|
|
nanoPrompting = true;
|
|
setNanoStatus('Save modified buffer?');
|
|
setNanoKeys([['Y', 'Yes'], ['N', 'No'], ['^C', 'Cancel']]);
|
|
}
|
|
|
|
function nanoEndPrompt(msg) {
|
|
nanoPrompting = false;
|
|
setNanoStatus(msg);
|
|
setNanoKeys(NANO_EDIT_KEYS);
|
|
}
|
|
|
|
// Writing works for root and no one else; sets its own status line
|
|
function nanoSave() {
|
|
if (user !== 'root') {
|
|
setNanoStatus('[ Error writing ' + nanoName + ': Permission denied ]');
|
|
return false;
|
|
}
|
|
if (!nanoSegs || !nanoSegs.length) {
|
|
setNanoStatus('[ Cancelled ]');
|
|
return false;
|
|
}
|
|
const parent = nodeAt(nanoSegs.slice(0, -1));
|
|
if (!parent || parent === DENIED || isFile(parent)) {
|
|
setNanoStatus('[ Directory does not exist ]');
|
|
return false;
|
|
}
|
|
parent.children[nanoSegs[nanoSegs.length - 1]] = nanoEdit.value;
|
|
nanoOriginal = nanoEdit.value;
|
|
updateNanoTitle();
|
|
const n = nanoEdit.value ? nanoEdit.value.replace(/\n$/, '').split('\n').length : 0;
|
|
setNanoStatus('[ Wrote ' + n + ' line' + (n === 1 ? '' : 's') + ' ]');
|
|
return true;
|
|
}
|
|
|
|
function nanoKeydown(e) {
|
|
if (nanoPrompting) {
|
|
e.preventDefault();
|
|
const k = e.key.toLowerCase();
|
|
if (k === 'y') {
|
|
nanoPrompting = false;
|
|
setNanoKeys(NANO_EDIT_KEYS);
|
|
if (nanoSave()) closeNano();
|
|
} else if (k === 'n') closeNano();
|
|
else if ((k === 'c' && e.ctrlKey) || e.key === 'Escape') nanoEndPrompt('[ Cancelled ]');
|
|
return;
|
|
}
|
|
if (e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
const k = e.key.toLowerCase();
|
|
if (k === 'x') {
|
|
e.preventDefault();
|
|
if (nanoEdit.value !== nanoOriginal) nanoAskExit();
|
|
else closeNano();
|
|
} else if (k === 'o' || k === 's') {
|
|
e.preventDefault();
|
|
nanoSave();
|
|
}
|
|
return; // other ctrl combos (copy, paste, undo) keep their defaults
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
if (nanoEdit.value !== nanoOriginal) nanoAskExit();
|
|
else closeNano();
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
nanoEdit.setRangeText('\t', nanoEdit.selectionStart, nanoEdit.selectionEnd, 'end');
|
|
updateNanoTitle();
|
|
}
|
|
}
|
|
|
|
function build() {
|
|
dialog = document.createElement('dialog');
|
|
dialog.className = 'term-dialog';
|
|
dialog.setAttribute('aria-label', 'Terminal');
|
|
|
|
const bar = document.createElement('div');
|
|
bar.className = 'term-titlebar';
|
|
termTitle = document.createElement('span');
|
|
termTitle.className = 'term-title';
|
|
termTitle.textContent = user + '@' + HOST;
|
|
bar.appendChild(termTitle);
|
|
|
|
screen = document.createElement('div');
|
|
screen.className = 'term-screen';
|
|
out = document.createElement('div');
|
|
out.className = 'term-out';
|
|
out.setAttribute('role', 'log');
|
|
const row = document.createElement('div');
|
|
row.className = 'term-inputrow';
|
|
promptEl = document.createElement('span');
|
|
promptEl.className = 'term-prompt';
|
|
promptEl.setAttribute('aria-hidden', 'true');
|
|
promptEl.textContent = promptText();
|
|
input = document.createElement('input');
|
|
input.className = 'term-input';
|
|
input.type = 'text';
|
|
input.autocapitalize = 'off';
|
|
input.autocomplete = 'off';
|
|
input.spellcheck = false;
|
|
input.setAttribute('aria-label', 'Command input');
|
|
row.append(promptEl, input);
|
|
screen.append(out, row);
|
|
dialog.append(bar, screen);
|
|
document.body.appendChild(dialog);
|
|
|
|
const motd = nodeAt(resolvePath('/etc/motd'));
|
|
if (typeof motd === 'string') print(motd.replace(/\n$/, ''));
|
|
|
|
wireInput(input);
|
|
// Click focuses the input, but not when selecting scrollback text
|
|
screen.addEventListener('click', () => {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.isCollapsed) input.focus();
|
|
});
|
|
// Click on the backdrop closes, like the delete dialog
|
|
dialog.addEventListener('click', (e) => {
|
|
if (e.target === dialog) dialog.close();
|
|
});
|
|
// While nano is up, Escape belongs to nano, not the dialog
|
|
dialog.addEventListener('cancel', (e) => {
|
|
if (screen.classList.contains('nano-open')) e.preventDefault();
|
|
});
|
|
}
|
|
|
|
function wireInput(el) {
|
|
el.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
const k = e.key.toLowerCase();
|
|
if (k === 'c') {
|
|
e.preventDefault();
|
|
if (pendingSu) {
|
|
pendingSu = null;
|
|
input.type = 'text';
|
|
line([{ text: 'Password:^C' }]);
|
|
promptEl.textContent = promptText();
|
|
} else {
|
|
line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: input.value + '^C' }]);
|
|
}
|
|
input.value = '';
|
|
hIdx = history.length;
|
|
draft = '';
|
|
screen.scrollTop = screen.scrollHeight;
|
|
} else if (k === 'd') {
|
|
e.preventDefault();
|
|
if (pendingSu) {
|
|
pendingSu = null;
|
|
input.type = 'text';
|
|
input.value = '';
|
|
line([{ text: 'Password:' }]);
|
|
promptEl.textContent = promptText();
|
|
} else if (input.value === '') COMMANDS.exit.run();
|
|
} else if (k === 'l') {
|
|
e.preventDefault();
|
|
out.textContent = '';
|
|
}
|
|
return; // unhandled ctrl combos (paste, …) keep their defaults
|
|
}
|
|
if (e.key === 'Enter') {
|
|
// preventDefault kills the trailing keypress: if a command opens a
|
|
// modal (rm -rf /), the keypress would land on its autofocused
|
|
// button and click it before the dialog is ever seen
|
|
e.preventDefault();
|
|
const raw = input.value;
|
|
input.value = '';
|
|
if (pendingSu) {
|
|
// password attempts stay out of scrollback and history
|
|
const target = pendingSu;
|
|
pendingSu = null;
|
|
input.type = 'text';
|
|
line([{ text: 'Password:' }]);
|
|
if (raw === PASSWORDS[target]) {
|
|
userStack.push(user);
|
|
becomeUser(target);
|
|
} else {
|
|
print('su: Authentication failure');
|
|
promptEl.textContent = promptText();
|
|
}
|
|
screen.scrollTop = screen.scrollHeight;
|
|
return;
|
|
}
|
|
runLine(raw);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (pendingSu) return;
|
|
if (hIdx > 0) {
|
|
if (hIdx === history.length) draft = input.value;
|
|
hIdx--;
|
|
input.value = history[hIdx];
|
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (pendingSu) return;
|
|
if (hIdx < history.length) {
|
|
hIdx++;
|
|
input.value = hIdx === history.length ? draft : history[hIdx];
|
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
}
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (!pendingSu) complete();
|
|
}
|
|
});
|
|
}
|
|
|
|
function open() {
|
|
if (!dialog) build(); // lazy: no trace in the DOM until first opened
|
|
if (dialog.open) return;
|
|
dialog.showModal();
|
|
if (screen.classList.contains('nano-open')) nanoEdit.focus();
|
|
else input.focus();
|
|
screen.scrollTop = screen.scrollHeight;
|
|
}
|
|
|
|
icon.addEventListener('click', open);
|
|
})();
|