diff --git a/internal/server/server.go b/internal/server/server.go index 43fe816..242b2d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -434,6 +434,11 @@ func assetHashes(fsys fs.FS) map[string]string { func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler { files := http.FileServer(http.FS(fsys)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The JS tests sit beside the scripts but are not served. + if strings.HasSuffix(r.URL.Path, ".test.js") { + http.NotFound(w, r) + return + } h := hashes[strings.TrimPrefix(r.URL.Path, "/")] if h != "" { w.Header().Set("ETag", `"`+h+`"`) diff --git a/internal/server/static_test.go b/internal/server/static_test.go new file mode 100644 index 0000000..37612e3 --- /dev/null +++ b/internal/server/static_test.go @@ -0,0 +1,23 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestStaticHidesTests(t *testing.T) { + fsys := fstest.MapFS{ + "wsutil.js": {Data: []byte("x")}, + "wsutil.test.js": {Data: []byte("y")}, + } + h := staticHandler(fsys, map[string]string{}) + for path, want := range map[string]int{"/wsutil.js": 200, "/wsutil.test.js": 404} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != want { + t.Errorf("%s: got %d, want %d", path, w.Code, want) + } + } +} diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js new file mode 100644 index 0000000..9a6accc --- /dev/null +++ b/internal/server/web/static/wsutil.js @@ -0,0 +1,27 @@ +// Pure helpers of the workspace: quoting paths for a shell, what a dropped +// file does where it lands, sizes. No DOM here, so node can test them. +(function (root) { + 'use strict'; + function shellQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'"; } + function shellQuoteAll(ps) { return ps.map(shellQuote).join(' '); } + const same = (a, b) => a.kind === b.kind && a.id === b.id; + // dropAction says what dropping item on target does: a transfer between + // file panes, paths pasted into a terminal of the same place, nothing on + // its own folder, or a refusal with the reason. + function dropAction(item, target) { + if (target.kind === 'term') { + return same(item.source, target.source) ? {kind: 'paste'} + : {kind: 'deny', reason: 'Нельзя: файл на ' + item.source.label + ', а терминал на ' + target.source.label}; + } + if (same(item.source, target.source) && target.dir === item.dir) return {kind: 'none'}; + return {kind: 'transfer'}; + } + function fmtSize(n) { + if (n < 1024) return n + ' B'; + if (n < 1048576) return (n / 1024).toFixed(1) + ' K'; + if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M'; + return (n / 1073741824).toFixed(1) + ' G'; + } + const api = {shellQuote, shellQuoteAll, dropAction, fmtSize}; + if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api; +})(this); diff --git a/internal/server/web/static/wsutil.test.js b/internal/server/web/static/wsutil.test.js new file mode 100644 index 0000000..a7b8cf0 --- /dev/null +++ b/internal/server/web/static/wsutil.test.js @@ -0,0 +1,35 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const U = require('./wsutil.js'); + +test('shellQuote keeps a path one literal word', () => { + assert.strictEqual(U.shellQuote('/srv/a b'), "'/srv/a b'"); + assert.strictEqual(U.shellQuote("/x/it's"), "'/x/it'\\''s'"); + assert.strictEqual(U.shellQuote('/x/$HOME;rm'), "'/x/$HOME;rm'"); + assert.strictEqual(U.shellQuoteAll(['/a', '/b c']), "'/a' '/b c'"); +}); + +const m1 = {kind: 'agent', id: 'm1', label: 'web'}; +const m2 = {kind: 'agent', id: 'm2', label: 'db'}; +const s1 = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'}; +const item = {source: m1, paths: ['/etc/x'], dir: '/etc'}; + +test('dropAction: files into a file pane is a transfer', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m2, dir: '/tmp'}), {kind: 'transfer'}); + assert.deepStrictEqual(U.dropAction(item, {kind: 'folder', source: m1, dir: '/var'}), {kind: 'transfer'}); +}); +test('dropAction: into its own folder does nothing', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m1, dir: '/etc'}), {kind: 'none'}); +}); +test('dropAction: terminal of the same place pastes, of another is denied', () => { + assert.deepStrictEqual(U.dropAction(item, {kind: 'term', source: m1}), {kind: 'paste'}); + const d = U.dropAction(item, {kind: 'term', source: m2}); + assert.strictEqual(d.kind, 'deny'); + assert.strictEqual(d.reason, 'Нельзя: файл на web, а терминал на db'); + assert.strictEqual(U.dropAction({source: s1, paths: ['/a'], dir: '/'}, {kind: 'term', source: s1}).kind, 'paste'); +}); +test('fmtSize', () => { + assert.strictEqual(U.fmtSize(10), '10 B'); + assert.strictEqual(U.fmtSize(2048), '2.0 K'); +});