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.
@@ -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
+ })();
@@ -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
+ };