saltcorn-samba 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/filemanager-view.js +249 -0
- package/index.js +622 -0
- package/package.json +56 -0
- package/pdf-view.js +163 -0
- package/public/samba-filemanager.js +736 -0
- package/public/samba-tree.js +292 -0
- package/public/samba.css +69 -0
- package/smb-client.js +279 -0
- package/tree-view.js +212 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side directory tree for the SambaTree view template.
|
|
3
|
+
*
|
|
4
|
+
* Exposes window.saltcornSambaMount(elementId). The tree lazily loads
|
|
5
|
+
* children from /sambadir?path=... and opens files either inline (PDF/image)
|
|
6
|
+
* in the built-in viewer <div> or by redirecting to /sambalink (smb://).
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
function h(tag, attrs, children) {
|
|
12
|
+
var el = document.createElement(tag);
|
|
13
|
+
if (attrs)
|
|
14
|
+
Object.keys(attrs).forEach(function (k) {
|
|
15
|
+
if (k === "class") el.className = attrs[k];
|
|
16
|
+
else if (k === "text") el.textContent = attrs[k];
|
|
17
|
+
else if (k.slice(0, 2) === "on")
|
|
18
|
+
el.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
|
19
|
+
else el.setAttribute(k, attrs[k]);
|
|
20
|
+
});
|
|
21
|
+
(children || []).forEach(function (c) {
|
|
22
|
+
if (c == null) return;
|
|
23
|
+
el.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
24
|
+
});
|
|
25
|
+
return el;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function iconFor(item) {
|
|
29
|
+
if (item.isDir) return "📁";
|
|
30
|
+
var n = (item.name || "").toLowerCase();
|
|
31
|
+
if (n.endsWith(".pdf")) return "📄";
|
|
32
|
+
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/.test(n)) return "🖼️";
|
|
33
|
+
if (/\.(docx?|odt|rtf|txt|md)$/.test(n)) return "📝";
|
|
34
|
+
if (/\.(xlsx?|ods|csv)$/.test(n)) return "📊";
|
|
35
|
+
if (/\.(zip|tar|gz|7z|rar)$/.test(n)) return "🗜️";
|
|
36
|
+
return "📎";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isViewable(name) {
|
|
40
|
+
var n = (name || "").toLowerCase();
|
|
41
|
+
return (
|
|
42
|
+
n.endsWith(".pdf") ||
|
|
43
|
+
/\.(png|jpe?g|gif|webp|svg|bmp)$/.test(n) ||
|
|
44
|
+
/\.(txt|md|json|xml|csv|html?)$/.test(n)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function joinPath(a, b) {
|
|
49
|
+
if (!a) return b;
|
|
50
|
+
if (!b) return a;
|
|
51
|
+
return (a.replace(/\/+$/, "") + "/" + b.replace(/^\/+/, "")).replace(
|
|
52
|
+
/\/+/g,
|
|
53
|
+
"/"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fmtSize(n) {
|
|
58
|
+
if (!n) return "";
|
|
59
|
+
var u = ["B", "KB", "MB", "GB", "TB"];
|
|
60
|
+
var i = 0;
|
|
61
|
+
while (n >= 1024 && i < u.length - 1) {
|
|
62
|
+
n /= 1024;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchDir(path, showHidden) {
|
|
69
|
+
var url =
|
|
70
|
+
"/sambadir?path=" +
|
|
71
|
+
encodeURIComponent(path || "") +
|
|
72
|
+
(showHidden ? "&show_hidden=1" : "");
|
|
73
|
+
var r = await fetch(url, { credentials: "same-origin" });
|
|
74
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
75
|
+
return r.json();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderList(container, path, items, opts, viewerEl) {
|
|
79
|
+
container.innerHTML = "";
|
|
80
|
+
var ul = h("ul", { class: "samba-tree-list list-unstyled mb-0" }, []);
|
|
81
|
+
|
|
82
|
+
items.forEach(function (item) {
|
|
83
|
+
var full = joinPath(path, item.name);
|
|
84
|
+
var childrenBox = h("div", { class: "samba-tree-children ms-3" }, []);
|
|
85
|
+
var toggle = h(
|
|
86
|
+
"span",
|
|
87
|
+
{
|
|
88
|
+
class: "samba-tree-toggle me-1",
|
|
89
|
+
text: item.isDir ? "▸" : " ",
|
|
90
|
+
style: "cursor:pointer;display:inline-block;width:1em;",
|
|
91
|
+
},
|
|
92
|
+
[]
|
|
93
|
+
);
|
|
94
|
+
var label = h(
|
|
95
|
+
"span",
|
|
96
|
+
{
|
|
97
|
+
class: "samba-tree-label",
|
|
98
|
+
text: iconFor(item) + " " + item.name,
|
|
99
|
+
style: "cursor:pointer;",
|
|
100
|
+
title: full,
|
|
101
|
+
},
|
|
102
|
+
[]
|
|
103
|
+
);
|
|
104
|
+
var meta = h(
|
|
105
|
+
"span",
|
|
106
|
+
{
|
|
107
|
+
class: "samba-tree-meta text-muted small ms-2",
|
|
108
|
+
text: item.isDir ? "" : fmtSize(item.size),
|
|
109
|
+
},
|
|
110
|
+
[]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
var externalBtn = null;
|
|
114
|
+
if (opts.exposeSmbLink) {
|
|
115
|
+
externalBtn = h(
|
|
116
|
+
"a",
|
|
117
|
+
{
|
|
118
|
+
class: "samba-tree-external btn btn-sm btn-link p-0 ms-2",
|
|
119
|
+
href: "/sambalink?path=" + encodeURIComponent(full),
|
|
120
|
+
target: "_blank",
|
|
121
|
+
title: "Open in file manager",
|
|
122
|
+
text: "↗",
|
|
123
|
+
},
|
|
124
|
+
[]
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var openDir = function () {
|
|
129
|
+
if (childrenBox.dataset.loaded === "1") {
|
|
130
|
+
var vis = childrenBox.style.display !== "none";
|
|
131
|
+
childrenBox.style.display = vis ? "none" : "block";
|
|
132
|
+
toggle.textContent = vis ? "▸" : "▾";
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
toggle.textContent = "…";
|
|
136
|
+
fetchDir(full, opts.showHidden)
|
|
137
|
+
.then(function (data) {
|
|
138
|
+
renderList(childrenBox, full, data.items || [], opts, viewerEl);
|
|
139
|
+
childrenBox.dataset.loaded = "1";
|
|
140
|
+
childrenBox.style.display = "block";
|
|
141
|
+
toggle.textContent = "▾";
|
|
142
|
+
})
|
|
143
|
+
.catch(function (e) {
|
|
144
|
+
childrenBox.innerHTML =
|
|
145
|
+
'<div class="text-danger small">Error: ' + e.message + "</div>";
|
|
146
|
+
childrenBox.dataset.loaded = "1";
|
|
147
|
+
toggle.textContent = "▸";
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
var openFile = function () {
|
|
152
|
+
if (!viewerEl) return;
|
|
153
|
+
if (opts.pdfInline && isViewable(item.name)) {
|
|
154
|
+
var url =
|
|
155
|
+
"/sambafile?path=" +
|
|
156
|
+
encodeURIComponent(full) +
|
|
157
|
+
"&disposition=inline";
|
|
158
|
+
var isImg = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(item.name);
|
|
159
|
+
viewerEl.innerHTML = "";
|
|
160
|
+
var header = h(
|
|
161
|
+
"div",
|
|
162
|
+
{ class: "samba-viewer-header d-flex align-items-center mb-2" },
|
|
163
|
+
[
|
|
164
|
+
h("strong", { text: item.name }, []),
|
|
165
|
+
h(
|
|
166
|
+
"a",
|
|
167
|
+
{
|
|
168
|
+
href: url,
|
|
169
|
+
target: "_blank",
|
|
170
|
+
class: "btn btn-sm btn-outline-secondary ms-auto me-2",
|
|
171
|
+
text: "Open in new tab",
|
|
172
|
+
},
|
|
173
|
+
[]
|
|
174
|
+
),
|
|
175
|
+
h(
|
|
176
|
+
"a",
|
|
177
|
+
{
|
|
178
|
+
href:
|
|
179
|
+
"/sambafile?path=" +
|
|
180
|
+
encodeURIComponent(full) +
|
|
181
|
+
"&disposition=attachment",
|
|
182
|
+
class: "btn btn-sm btn-outline-secondary me-2",
|
|
183
|
+
text: "Download",
|
|
184
|
+
},
|
|
185
|
+
[]
|
|
186
|
+
),
|
|
187
|
+
opts.exposeSmbLink
|
|
188
|
+
? h(
|
|
189
|
+
"a",
|
|
190
|
+
{
|
|
191
|
+
href: "/sambalink?path=" + encodeURIComponent(full),
|
|
192
|
+
target: "_blank",
|
|
193
|
+
class: "btn btn-sm btn-outline-primary",
|
|
194
|
+
text: "Open in file manager",
|
|
195
|
+
},
|
|
196
|
+
[]
|
|
197
|
+
)
|
|
198
|
+
: null,
|
|
199
|
+
]
|
|
200
|
+
);
|
|
201
|
+
viewerEl.appendChild(header);
|
|
202
|
+
if (isImg) {
|
|
203
|
+
viewerEl.appendChild(
|
|
204
|
+
h(
|
|
205
|
+
"img",
|
|
206
|
+
{
|
|
207
|
+
src: url,
|
|
208
|
+
style:
|
|
209
|
+
"max-width:100%;max-height:70vh;border:1px solid #dee2e6;border-radius:4px;",
|
|
210
|
+
},
|
|
211
|
+
[]
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
viewerEl.appendChild(
|
|
216
|
+
h(
|
|
217
|
+
"iframe",
|
|
218
|
+
{
|
|
219
|
+
src: url,
|
|
220
|
+
style:
|
|
221
|
+
"width:100%;height:70vh;border:1px solid #dee2e6;border-radius:4px;",
|
|
222
|
+
},
|
|
223
|
+
[]
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
// Not inline-viewable – fall back to the external link page.
|
|
229
|
+
window.open(
|
|
230
|
+
"/sambalink?path=" + encodeURIComponent(full),
|
|
231
|
+
"_blank"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
label.addEventListener("click", function () {
|
|
237
|
+
if (item.isDir) openDir();
|
|
238
|
+
else openFile();
|
|
239
|
+
});
|
|
240
|
+
toggle.addEventListener("click", function () {
|
|
241
|
+
if (item.isDir) openDir();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
var lineChildren = [toggle, label, meta];
|
|
245
|
+
if (externalBtn) lineChildren.push(externalBtn);
|
|
246
|
+
|
|
247
|
+
var li = h("li", { class: "samba-tree-item" }, [
|
|
248
|
+
h("div", { class: "samba-tree-line d-flex align-items-center" }, lineChildren),
|
|
249
|
+
childrenBox,
|
|
250
|
+
]);
|
|
251
|
+
childrenBox.style.display = "none";
|
|
252
|
+
ul.appendChild(li);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!items.length) {
|
|
256
|
+
container.appendChild(
|
|
257
|
+
h(
|
|
258
|
+
"div",
|
|
259
|
+
{ class: "text-muted small fst-italic p-2", text: "(empty)" },
|
|
260
|
+
[]
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
container.appendChild(ul);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mount(elId) {
|
|
269
|
+
var el = document.getElementById(elId);
|
|
270
|
+
if (!el || el.dataset.mounted === "1") return;
|
|
271
|
+
el.dataset.mounted = "1";
|
|
272
|
+
var viewer = document.getElementById(elId + "-viewer");
|
|
273
|
+
var opts = {};
|
|
274
|
+
try {
|
|
275
|
+
opts = JSON.parse(el.getAttribute("data-opts") || "{}");
|
|
276
|
+
} catch (_) {
|
|
277
|
+
opts = {};
|
|
278
|
+
}
|
|
279
|
+
el.innerHTML =
|
|
280
|
+
'<div class="text-muted small p-2">Loading…</div>';
|
|
281
|
+
fetchDir(opts.startPath || "", opts.showHidden)
|
|
282
|
+
.then(function (data) {
|
|
283
|
+
renderList(el, opts.startPath || "", data.items || [], opts, viewer);
|
|
284
|
+
})
|
|
285
|
+
.catch(function (e) {
|
|
286
|
+
el.innerHTML =
|
|
287
|
+
'<div class="alert alert-danger">Samba: ' + e.message + "</div>";
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
window.saltcornSambaMount = mount;
|
|
292
|
+
})();
|
package/public/samba.css
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* ---- shared tree styles ---- */
|
|
2
|
+
.samba-tree-container { font-size: .95rem; }
|
|
3
|
+
.samba-tree-list { padding-left: 0; margin: 0; }
|
|
4
|
+
.samba-tree-item { padding: 2px 0; }
|
|
5
|
+
.samba-tree-line:hover { background: rgba(0,0,0,.03); border-radius: 3px; }
|
|
6
|
+
.samba-tree-label { user-select: none; }
|
|
7
|
+
.samba-tree-toggle { color: #666; font-family: monospace; }
|
|
8
|
+
.samba-tree-meta { flex-shrink: 0; }
|
|
9
|
+
.samba-tree-external { text-decoration: none; }
|
|
10
|
+
.samba-viewer { min-height: 0; }
|
|
11
|
+
|
|
12
|
+
/* ---- file manager ---- */
|
|
13
|
+
.samba-fm .card-header { background: transparent; }
|
|
14
|
+
.samba-fm-toolbar { background: #fafafa; display: flex; align-items: center; flex-wrap: wrap; gap: .25rem; }
|
|
15
|
+
.samba-fm-toolbar .breadcrumb { background: transparent; padding: 0; margin: 0; font-size: .9rem; }
|
|
16
|
+
.samba-fm-table thead th {
|
|
17
|
+
position: sticky; top: 0; background: #fff; z-index: 1;
|
|
18
|
+
border-bottom: 2px solid #dee2e6;
|
|
19
|
+
font-weight: 600;
|
|
20
|
+
}
|
|
21
|
+
.samba-fm-table tbody tr:hover { background: rgba(13,110,253,.04); }
|
|
22
|
+
.samba-fm-icon { text-align: center; }
|
|
23
|
+
.samba-fm-name { text-decoration: none; color: inherit; }
|
|
24
|
+
.samba-fm-name:hover { text-decoration: underline; color: #0d6efd; }
|
|
25
|
+
.samba-fm-viewer:empty { display: none; }
|
|
26
|
+
.samba-fm-viewer iframe { background: #fff; }
|
|
27
|
+
|
|
28
|
+
/* ---- modal ---- */
|
|
29
|
+
.samba-modal-backdrop {
|
|
30
|
+
position: fixed; inset: 0;
|
|
31
|
+
background: rgba(0,0,0,.45);
|
|
32
|
+
display: flex; align-items: center; justify-content: center;
|
|
33
|
+
z-index: 2000;
|
|
34
|
+
}
|
|
35
|
+
.samba-modal {
|
|
36
|
+
width: min(560px, 92vw);
|
|
37
|
+
max-height: 90vh; overflow: auto;
|
|
38
|
+
background: #fff;
|
|
39
|
+
box-shadow: 0 10px 40px rgba(0,0,0,.25);
|
|
40
|
+
}
|
|
41
|
+
.samba-modal-body { padding: 1rem 1.25rem; }
|
|
42
|
+
.samba-modal-footer { padding: .75rem 1rem; background: #f8f9fa; }
|
|
43
|
+
|
|
44
|
+
/* ---- toast ---- */
|
|
45
|
+
.samba-toast {
|
|
46
|
+
position: fixed; right: 1rem; bottom: 1rem;
|
|
47
|
+
background: #212529; color: #fff;
|
|
48
|
+
padding: .6rem 1rem; border-radius: 6px;
|
|
49
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.25);
|
|
50
|
+
z-index: 2100;
|
|
51
|
+
transition: opacity .25s, transform .25s;
|
|
52
|
+
max-width: 420px;
|
|
53
|
+
}
|
|
54
|
+
.samba-toast-success { background: #198754; }
|
|
55
|
+
.samba-toast-error { background: #dc3545; }
|
|
56
|
+
.samba-toast-hide { opacity: 0; transform: translateY(10px); }
|
|
57
|
+
|
|
58
|
+
/* ---- upload drop zone ---- */
|
|
59
|
+
.samba-drop {
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
transition: background .15s, border-color .15s;
|
|
62
|
+
border-style: dashed !important;
|
|
63
|
+
}
|
|
64
|
+
.samba-drop:hover, .samba-drop-active {
|
|
65
|
+
background: rgba(13,110,253,.06);
|
|
66
|
+
border-color: #0d6efd !important;
|
|
67
|
+
color: #0d6efd;
|
|
68
|
+
}
|
|
69
|
+
.samba-picked ul { max-height: 12rem; overflow: auto; }
|
package/smb-client.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMB client wrapper – provides a small, connection-pooled interface
|
|
3
|
+
* around @marsaud/smb2 with security-conscious path handling.
|
|
4
|
+
*
|
|
5
|
+
* All paths that come from the browser MUST be validated with
|
|
6
|
+
* `sanitizeRelativePath` before being combined with the base_path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const SMB2 = require("@marsaud/smb2");
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Path helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate a single file/directory name component (no path separators!).
|
|
18
|
+
* Rejects empty, dot-only, path separators, control chars, and reserved
|
|
19
|
+
* Windows device names. Returns the trimmed name on success, throws on
|
|
20
|
+
* invalid input.
|
|
21
|
+
*/
|
|
22
|
+
function sanitizeFilename(name) {
|
|
23
|
+
if (name === undefined || name === null) throw new Error("Filename required");
|
|
24
|
+
if (typeof name !== "string") throw new Error("Filename must be a string");
|
|
25
|
+
const trimmed = name.trim();
|
|
26
|
+
if (!trimmed) throw new Error("Filename must not be empty");
|
|
27
|
+
// reject leading/trailing whitespace on non-empty names (Windows problem)
|
|
28
|
+
if (name !== trimmed)
|
|
29
|
+
throw new Error("Filename must not start or end with whitespace");
|
|
30
|
+
if (trimmed.length > 255) throw new Error("Filename too long");
|
|
31
|
+
if (trimmed === "." || trimmed === "..")
|
|
32
|
+
throw new Error("Filename must not be '.' or '..'");
|
|
33
|
+
if (/[\\/]/.test(trimmed)) throw new Error("Filename must not contain slashes");
|
|
34
|
+
if (/[\x00-\x1f]/.test(trimmed)) throw new Error("Filename must not contain control characters");
|
|
35
|
+
// Reject characters SMB / Windows disallow in filenames
|
|
36
|
+
if (/[<>:"|?*]/.test(trimmed))
|
|
37
|
+
throw new Error('Filename must not contain any of: < > : " | ? *');
|
|
38
|
+
if (trimmed.endsWith(".") || trimmed.endsWith(" "))
|
|
39
|
+
throw new Error("Filename must not end with a dot or space");
|
|
40
|
+
// Windows reserved device names (case-insensitive, with or without extension)
|
|
41
|
+
const base = trimmed.split(".")[0].toUpperCase();
|
|
42
|
+
const RESERVED = new Set([
|
|
43
|
+
"CON", "PRN", "AUX", "NUL",
|
|
44
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
45
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
46
|
+
]);
|
|
47
|
+
if (RESERVED.has(base)) throw new Error("Filename uses a reserved device name");
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reject path traversal, absolute paths, drive letters, NUL bytes, and other
|
|
53
|
+
* suspicious content. Returns a normalised POSIX-style relative path (no
|
|
54
|
+
* leading/trailing separators). Throws on invalid input.
|
|
55
|
+
*/
|
|
56
|
+
function sanitizeRelativePath(rel) {
|
|
57
|
+
if (rel === undefined || rel === null || rel === "") return "";
|
|
58
|
+
if (typeof rel !== "string") throw new Error("Path must be a string");
|
|
59
|
+
if (rel.length > 4096) throw new Error("Path too long");
|
|
60
|
+
if (rel.includes("\0")) throw new Error("Illegal NUL byte in path");
|
|
61
|
+
// Convert backslashes to forward slashes first (do NOT collapse yet)
|
|
62
|
+
let p = rel.replace(/\\/g, "/");
|
|
63
|
+
// Reject Windows drive letters and UNC paths BEFORE collapsing slashes
|
|
64
|
+
if (/^[a-zA-Z]:/.test(p)) throw new Error("Drive letters not allowed");
|
|
65
|
+
if (p.startsWith("//")) throw new Error("UNC paths not allowed");
|
|
66
|
+
// Now collapse multiple slashes and strip leading slash
|
|
67
|
+
p = p.replace(/\/+/g, "/");
|
|
68
|
+
if (p.startsWith("/")) p = p.slice(1);
|
|
69
|
+
// Reject explicit traversal segments
|
|
70
|
+
const parts = p.split("/").filter((s) => s !== "" && s !== ".");
|
|
71
|
+
for (const seg of parts) {
|
|
72
|
+
if (seg === "..") throw new Error("Path traversal not allowed");
|
|
73
|
+
}
|
|
74
|
+
return parts.join("/");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Convert a POSIX-style relative path into the SMB backslash form. */
|
|
78
|
+
function toSmbPath(rel) {
|
|
79
|
+
return rel.replace(/\//g, "\\");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// SMB client factory
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a fresh SMB2 client from the plugin configuration.
|
|
88
|
+
*
|
|
89
|
+
* config = {
|
|
90
|
+
* server: "192.168.1.10", // host or IP of the samba server
|
|
91
|
+
* share: "documents", // share name (no slashes)
|
|
92
|
+
* domain: "WORKGROUP", // optional
|
|
93
|
+
* username: "reader",
|
|
94
|
+
* password: "secret",
|
|
95
|
+
* base_path:"", // optional subdirectory to lock into
|
|
96
|
+
* port: 445, // optional
|
|
97
|
+
* }
|
|
98
|
+
*
|
|
99
|
+
* The returned object exposes: readdir(rel), stat(rel), readFile(rel),
|
|
100
|
+
* createReadStream(rel), disconnect(), plus helpers `resolve(rel)` and
|
|
101
|
+
* `basePath`.
|
|
102
|
+
*/
|
|
103
|
+
function buildClient(config) {
|
|
104
|
+
if (!config) throw new Error("Samba plugin is not configured");
|
|
105
|
+
const { server, share, domain, username, password, port } = config;
|
|
106
|
+
if (!server) throw new Error("Samba: server missing");
|
|
107
|
+
if (!share) throw new Error("Samba: share missing");
|
|
108
|
+
if (/[\\/]/.test(share)) throw new Error("Samba: share must not contain slashes");
|
|
109
|
+
|
|
110
|
+
const shareStr = `\\\\${server}${port ? ":" + port : ""}\\${share}`;
|
|
111
|
+
const smb = new SMB2({
|
|
112
|
+
share: shareStr,
|
|
113
|
+
domain: domain || "WORKGROUP",
|
|
114
|
+
username: username || "guest",
|
|
115
|
+
password: password || "",
|
|
116
|
+
autoCloseTimeout: 10000,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const basePath = sanitizeRelativePath(config.base_path || "");
|
|
120
|
+
|
|
121
|
+
/** Combine base + user-supplied relative into a validated SMB path. */
|
|
122
|
+
function resolve(rel) {
|
|
123
|
+
const safe = sanitizeRelativePath(rel);
|
|
124
|
+
const combined = [basePath, safe].filter(Boolean).join("/");
|
|
125
|
+
return toSmbPath(combined);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
basePath,
|
|
130
|
+
resolve,
|
|
131
|
+
readdir(rel) {
|
|
132
|
+
return new Promise((res, rej) => {
|
|
133
|
+
smb.readdir(resolve(rel), { stats: true }, (err, files) =>
|
|
134
|
+
err ? rej(err) : res(files)
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
stat(rel) {
|
|
139
|
+
return new Promise((res, rej) => {
|
|
140
|
+
// marsaud-smb2 does not export a proper stat; emulate via readdir of parent
|
|
141
|
+
const target = resolve(rel);
|
|
142
|
+
const parent = target.includes("\\")
|
|
143
|
+
? target.slice(0, target.lastIndexOf("\\"))
|
|
144
|
+
: "";
|
|
145
|
+
const name = target.includes("\\")
|
|
146
|
+
? target.slice(target.lastIndexOf("\\") + 1)
|
|
147
|
+
: target;
|
|
148
|
+
smb.readdir(parent, { stats: true }, (err, files) => {
|
|
149
|
+
if (err) return rej(err);
|
|
150
|
+
const match = files.find((f) => f.name === name);
|
|
151
|
+
if (!match) return rej(new Error("Not found"));
|
|
152
|
+
res(match);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
readFile(rel) {
|
|
157
|
+
return new Promise((res, rej) => {
|
|
158
|
+
smb.readFile(resolve(rel), (err, data) =>
|
|
159
|
+
err ? rej(err) : res(data)
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
writeFile(rel, data) {
|
|
164
|
+
return new Promise((res, rej) => {
|
|
165
|
+
smb.writeFile(resolve(rel), data, (err) =>
|
|
166
|
+
err ? rej(err) : res()
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
exists(rel) {
|
|
171
|
+
return new Promise((res) => {
|
|
172
|
+
smb.exists(resolve(rel), (err, ok) => res(err ? false : !!ok));
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
unlink(rel) {
|
|
176
|
+
return new Promise((res, rej) => {
|
|
177
|
+
smb.unlink(resolve(rel), (err) => (err ? rej(err) : res()));
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
rmdir(rel) {
|
|
181
|
+
return new Promise((res, rej) => {
|
|
182
|
+
smb.rmdir(resolve(rel), (err) => (err ? rej(err) : res()));
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
mkdir(rel) {
|
|
186
|
+
return new Promise((res, rej) => {
|
|
187
|
+
smb.mkdir(resolve(rel), (err) => (err ? rej(err) : res()));
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
rename(oldRel, newRel) {
|
|
191
|
+
return new Promise((res, rej) => {
|
|
192
|
+
smb.rename(resolve(oldRel), resolve(newRel), (err) =>
|
|
193
|
+
err ? rej(err) : res()
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
createReadStream(rel) {
|
|
198
|
+
return new Promise((res, rej) => {
|
|
199
|
+
smb.createReadStream(resolve(rel), (err, stream) =>
|
|
200
|
+
err ? rej(err) : res(stream)
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
disconnect() {
|
|
205
|
+
try {
|
|
206
|
+
smb.disconnect();
|
|
207
|
+
} catch (_) {
|
|
208
|
+
// ignore
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Execute a callback with a fresh SMB client and always disconnect afterwards.
|
|
216
|
+
* Prefer this helper over building a long-lived client because SMB sessions
|
|
217
|
+
* can time out and marsaud-smb2 does not handle reconnects well.
|
|
218
|
+
*/
|
|
219
|
+
async function withClient(config, fn) {
|
|
220
|
+
const client = buildClient(config);
|
|
221
|
+
try {
|
|
222
|
+
return await fn(client);
|
|
223
|
+
} finally {
|
|
224
|
+
client.disconnect();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// URL helpers
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/** Build a browser-openable smb:// URL for external file managers. */
|
|
233
|
+
function toSmbUrl(config, rel) {
|
|
234
|
+
const safe = sanitizeRelativePath(rel);
|
|
235
|
+
const basePath = sanitizeRelativePath(config.base_path || "");
|
|
236
|
+
const parts = [basePath, safe].filter(Boolean).join("/");
|
|
237
|
+
const encoded = parts.split("/").map(encodeURIComponent).join("/");
|
|
238
|
+
return `smb://${config.server}/${encodeURIComponent(config.share)}${
|
|
239
|
+
encoded ? "/" + encoded : ""
|
|
240
|
+
}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Guess a MIME type from a filename – kept tiny and dependency-free. */
|
|
244
|
+
function mimeFromName(name) {
|
|
245
|
+
const ext = String(name || "").toLowerCase().split(".").pop();
|
|
246
|
+
const map = {
|
|
247
|
+
pdf: "application/pdf",
|
|
248
|
+
png: "image/png",
|
|
249
|
+
jpg: "image/jpeg",
|
|
250
|
+
jpeg: "image/jpeg",
|
|
251
|
+
gif: "image/gif",
|
|
252
|
+
webp: "image/webp",
|
|
253
|
+
svg: "image/svg+xml",
|
|
254
|
+
txt: "text/plain; charset=utf-8",
|
|
255
|
+
md: "text/markdown; charset=utf-8",
|
|
256
|
+
csv: "text/csv; charset=utf-8",
|
|
257
|
+
json: "application/json",
|
|
258
|
+
xml: "application/xml",
|
|
259
|
+
html: "text/html; charset=utf-8",
|
|
260
|
+
zip: "application/zip",
|
|
261
|
+
doc: "application/msword",
|
|
262
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
263
|
+
xls: "application/vnd.ms-excel",
|
|
264
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
265
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
266
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
267
|
+
};
|
|
268
|
+
return map[ext] || "application/octet-stream";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
buildClient,
|
|
273
|
+
withClient,
|
|
274
|
+
sanitizeRelativePath,
|
|
275
|
+
sanitizeFilename,
|
|
276
|
+
toSmbPath,
|
|
277
|
+
toSmbUrl,
|
|
278
|
+
mimeFromName,
|
|
279
|
+
};
|