saltcorn-samba 0.4.14 → 0.4.16

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/i18n.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * saltcorn-samba – Minimales i18n-Modul (Server-Seite).
3
+ *
4
+ * Design-Ziele:
5
+ * - Keine externe Dependency (kein i18next, keine intl-messageformat).
6
+ * - JSON-Kataloge unter ./i18n/<locale>.json.
7
+ * - Lazy load + Cache; unbekannte Locales fallen automatisch auf `en`.
8
+ * - Placeholder-Syntax {name} wird durch die übergebenen Werte ersetzt.
9
+ *
10
+ * Locale-Auflösung (in dieser Reihenfolge):
11
+ * 1. Explizit übergebener Wert (Request-Query ?locale=de).
12
+ * 2. `req.getLocale()` (Saltcorn setzt das aus Cookie / Accept-Language).
13
+ * 3. `req.headers["accept-language"]` – erste passende Locale.
14
+ * 4. Fallback = `en`.
15
+ *
16
+ * Die Client-Seite bekommt den passenden Katalog via Route
17
+ * GET /samba-i18n.json?locale=xx
18
+ * (siehe index.js). Dort ruft samba-common.js `SambaCommon.setCatalog(...)` auf.
19
+ */
20
+
21
+ "use strict";
22
+
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+
26
+ const CATALOG_DIR = path.join(__dirname, "i18n");
27
+ const DEFAULT_LOCALE = "en";
28
+ const catalogs = Object.create(null); // locale -> { key: str }
29
+ const availableLocales = new Set(["de", "en"]);
30
+
31
+ /**
32
+ * Load a catalogue from disk. Cached forever (plugin restart to reload).
33
+ * Missing files silently return {} — the caller falls back to the key itself.
34
+ */
35
+ function loadCatalog(locale) {
36
+ if (catalogs[locale]) return catalogs[locale];
37
+ const file = path.join(CATALOG_DIR, locale + ".json");
38
+ try {
39
+ const raw = fs.readFileSync(file, "utf8");
40
+ const parsed = JSON.parse(raw);
41
+ delete parsed.$meta; // metadata is not a translation
42
+ catalogs[locale] = parsed;
43
+ } catch (_) {
44
+ catalogs[locale] = {};
45
+ }
46
+ return catalogs[locale];
47
+ }
48
+
49
+ /**
50
+ * Resolve a locale string to one we actually ship a catalogue for.
51
+ * Accepts full IETF tags ("de-DE", "en-US") and language-only ("de", "en").
52
+ */
53
+ function normaliseLocale(raw) {
54
+ if (!raw) return DEFAULT_LOCALE;
55
+ const lc = String(raw).toLowerCase().trim();
56
+ if (availableLocales.has(lc)) return lc;
57
+ const short = lc.split(/[-_]/)[0];
58
+ if (availableLocales.has(short)) return short;
59
+ return DEFAULT_LOCALE;
60
+ }
61
+
62
+ /**
63
+ * Extract the caller's preferred locale from an Express-like request object.
64
+ * Never throws; falls back to DEFAULT_LOCALE.
65
+ */
66
+ function resolveLocaleFromReq(req, explicit) {
67
+ if (explicit) return normaliseLocale(explicit);
68
+ try {
69
+ if (req) {
70
+ // Saltcorn / i18next-express-middleware
71
+ if (typeof req.getLocale === "function") {
72
+ const l = req.getLocale();
73
+ if (l) return normaliseLocale(l);
74
+ }
75
+ // Explicit query ?locale=de
76
+ if (req.query && req.query.locale) {
77
+ return normaliseLocale(req.query.locale);
78
+ }
79
+ // Accept-Language header – take the first tag we understand.
80
+ const hdr = req.headers && req.headers["accept-language"];
81
+ if (hdr) {
82
+ const first = String(hdr).split(",")[0].split(";")[0];
83
+ return normaliseLocale(first);
84
+ }
85
+ }
86
+ } catch (_) {
87
+ // fall through
88
+ }
89
+ return DEFAULT_LOCALE;
90
+ }
91
+
92
+ /**
93
+ * Substitute {placeholder} tokens in the message. Missing values render as "".
94
+ */
95
+ function interpolate(msg, params) {
96
+ if (!params) return msg;
97
+ return String(msg).replace(/\{(\w+)\}/g, (_, key) =>
98
+ Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : ""
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Translate a single key. Falls back to English, then to the key itself so
104
+ * a missing translation never breaks the UI.
105
+ *
106
+ * t("fm.upload.button", { locale: "de" }) → "Hochladen"
107
+ * t("fm.deleted_ok", { locale: "de", name: "foo.txt" }) → "„foo.txt\" gelöscht"
108
+ */
109
+ function t(key, params) {
110
+ const opts = params || {};
111
+ const locale = normaliseLocale(opts.locale);
112
+ const primary = loadCatalog(locale);
113
+ if (Object.prototype.hasOwnProperty.call(primary, key)) {
114
+ return interpolate(primary[key], opts);
115
+ }
116
+ if (locale !== DEFAULT_LOCALE) {
117
+ const fallback = loadCatalog(DEFAULT_LOCALE);
118
+ if (Object.prototype.hasOwnProperty.call(fallback, key)) {
119
+ return interpolate(fallback[key], opts);
120
+ }
121
+ }
122
+ return key;
123
+ }
124
+
125
+ /**
126
+ * Bind a locale so callers can write `const _ = tFor("de"); _("ui.close")`.
127
+ */
128
+ function tFor(locale) {
129
+ const loc = normaliseLocale(locale);
130
+ return function boundTranslate(key, params) {
131
+ return t(key, Object.assign({}, params || {}, { locale: loc }));
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Return the raw catalogue for a locale, merged over the English defaults.
137
+ * Used to ship a single JSON blob to the browser.
138
+ */
139
+ function catalogFor(locale) {
140
+ const loc = normaliseLocale(locale);
141
+ const en = loadCatalog(DEFAULT_LOCALE);
142
+ if (loc === DEFAULT_LOCALE) return Object.assign({}, en);
143
+ const merged = Object.assign({}, en, loadCatalog(loc));
144
+ return merged;
145
+ }
146
+
147
+ module.exports = {
148
+ DEFAULT_LOCALE,
149
+ availableLocales: Array.from(availableLocales),
150
+ t,
151
+ tFor,
152
+ catalogFor,
153
+ resolveLocaleFromReq,
154
+ normaliseLocale,
155
+ };
package/index.js CHANGED
@@ -50,6 +50,11 @@ const {
50
50
  } = require("./smb-client");
51
51
  const treeView = require("./tree-view");
52
52
  const fileManagerView = require("./filemanager-view");
53
+ const {
54
+ catalogFor,
55
+ resolveLocaleFromReq,
56
+ availableLocales,
57
+ } = require("./i18n");
53
58
  // pdf-view is intentionally NOT wired into the manifest (see note at bottom).
54
59
  // The file is kept in the package so the DB-linkage release can revive it.
55
60
 
@@ -519,22 +524,18 @@ function jsonOk(res, extra) {
519
524
  }
520
525
 
521
526
  /**
522
- * Validate that the request carries a CSRF token matching the session.
523
- * Saltcorn injects `req.csrfToken()` when the CSRF middleware is active;
524
- * we accept either the `_csrf` body field or the `x-csrf-token` header.
527
+ * CSRF verification is handled by Saltcorn's global csurf middleware
528
+ * (packages/server/app.js). By the time our route handler runs, csurf
529
+ * has already validated req.body._csrf / X-CSRF-Token / csrf-token /
530
+ * xsrf-token headers against the session secret. A manual re-check
531
+ * here would be wrong: csrf-tokens returns a NEW salted token on
532
+ * every call to req.csrfToken(), so `provided !== req.csrfToken()`
533
+ * would fail even for perfectly valid requests.
534
+ *
535
+ * We keep this stub as the single documented spot to attach future
536
+ * pre-flight authorisation checks if ever needed.
525
537
  */
526
- function checkCsrf(req, res) {
527
- if (typeof req.csrfToken !== "function") return true; // CSRF disabled globally
528
- const expected = req.csrfToken();
529
- const provided =
530
- (req.body && req.body._csrf) ||
531
- req.headers["x-csrf-token"] ||
532
- req.headers["csrf-token"] ||
533
- req.query._csrf;
534
- if (!provided || provided !== expected) {
535
- jsonError(res, 403, "Invalid CSRF token");
536
- return false;
537
- }
538
+ function checkCsrf(_req, _res) {
538
539
  return true;
539
540
  }
540
541
 
@@ -1267,6 +1268,35 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
1267
1268
  },
1268
1269
  },
1269
1270
 
1271
+ {
1272
+ // GET /samba-i18n.json?locale=xx
1273
+ //
1274
+ // Liefert den vollen \u00dcbersetzungskatalog f\u00fcr die Client-Seite. Wird
1275
+ // aktuell NICHT vom Standard-Bootstrap benutzt (die View-Shells injizieren
1276
+ // den Katalog inline, spart einen Roundtrip). Die Route ist f\u00fcr
1277
+ // externe Consumer / Debugging / dynamisches Nachladen einer anderen
1278
+ // Sprache im Browser gedacht.
1279
+ //
1280
+ // Keine Authentifizierung: Katalog enth\u00e4lt nur \u00dcbersetzungstext,
1281
+ // keine Konfiguration und keine share-spezifischen Daten.
1282
+ url: "/samba-i18n.json",
1283
+ method: "get",
1284
+ callback: async (req, res) => {
1285
+ try {
1286
+ const locale = resolveLocaleFromReq(req, req.query && req.query.locale);
1287
+ const catalog = catalogFor(locale);
1288
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1289
+ // Kurzer Cache: Katalog \u00e4ndert sich nur bei Plugin-Release.
1290
+ res.setHeader("Cache-Control", "public, max-age=300");
1291
+ res.setHeader("X-Samba-Locale", locale);
1292
+ res.setHeader("X-Samba-Available-Locales", availableLocales.join(","));
1293
+ res.json(catalog);
1294
+ } catch (e) {
1295
+ res.status(500).json({ error: "i18n: " + (e.message || String(e)) });
1296
+ }
1297
+ },
1298
+ },
1299
+
1270
1300
  {
1271
1301
  url: "/sambamkdir",
1272
1302
  method: "post",
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "saltcorn-samba",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Saltcorn plugin: browse, upload, rename and delete files on a Samba/CIFS share via SMB 3.1.1 (AES-CMAC signing, optional encryption). File-manager view, directory tree, inline PDF viewer, external-app open (smb://).",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node test/sanitize.test.js",
8
- "lint": "node --check index.js && node --check smb-client.js && node --check readdir-compat.js && node --check tree-view.js && node --check filemanager-view.js && node --check pdf-view.js && node --check public/samba-tree.js && node --check public/samba-filemanager.js",
8
+ "lint": "node --check index.js && node --check smb-client.js && node --check readdir-compat.js && node --check tree-view.js && node --check filemanager-view.js && node --check pdf-view.js && node --check i18n.js && node --check public/samba-tree.js && node --check public/samba-filemanager.js && node --check public/samba-common.js",
9
9
  "prepublishOnly": "npm run lint && npm test"
10
10
  },
11
11
  "keywords": [
@@ -43,6 +43,8 @@
43
43
  "tree-view.js",
44
44
  "filemanager-view.js",
45
45
  "pdf-view.js",
46
+ "i18n.js",
47
+ "i18n/",
46
48
  "public/",
47
49
  "tools/",
48
50
  "README.md",
package/pdf-view.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  button,
21
21
  } = require("@saltcorn/markup/tags");
22
22
  const { text } = require("@saltcorn/markup");
23
+ const { tFor, resolveLocaleFromReq } = require("./i18n");
23
24
 
24
25
  /** Extract the file extension in lowercase, without the dot. */
25
26
  function extOf(name) {
@@ -88,7 +89,9 @@ const samba_pdf = {
88
89
  },
89
90
  ],
90
91
  run: (value, req, options = {}) => {
91
- if (!value) return span({ class: "text-muted" }, "");
92
+ // Locale aus dem Saltcorn-Request; bei fehlendem req greift automatisch "en".
93
+ const _t = tFor(resolveLocaleFromReq(req));
94
+ if (!value) return span({ class: "text-muted" }, _t("pdf.empty"));
92
95
  const safeVal = text(String(value));
93
96
  const height = Number(options.height) > 0 ? Number(options.height) : 700;
94
97
 
@@ -101,7 +104,7 @@ const samba_pdf = {
101
104
  class: "btn btn-sm btn-outline-secondary me-2",
102
105
  },
103
106
  i({ class: "fas fa-download me-1" }),
104
- "Download"
107
+ _t("pdf.download")
105
108
  )
106
109
  );
107
110
  }
@@ -111,10 +114,10 @@ const samba_pdf = {
111
114
  {
112
115
  href: smbUrl(safeVal),
113
116
  class: "btn btn-sm btn-outline-primary me-2",
114
- title: "Open in Nemo/Nautilus/Explorer",
117
+ title: _t("pdf.open_in_fm_title"),
115
118
  },
116
119
  i({ class: "fas fa-external-link-alt me-1" }),
117
- "Open in file manager"
120
+ _t("pdf.open_in_fm")
118
121
  )
119
122
  );
120
123
  }
@@ -126,7 +129,7 @@ const samba_pdf = {
126
129
  class: "btn btn-sm btn-outline-secondary",
127
130
  },
128
131
  i({ class: "fas fa-eye me-1" }),
129
- "Open in new tab"
132
+ _t("pdf.open_new_tab")
130
133
  )
131
134
  );
132
135
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * saltcorn-samba – Gemeinsame Client-Utilities für den Browser.
3
+ *
4
+ * Wird von samba-filemanager.js und samba-tree.js benutzt. Enthält:
5
+ * - iconFor(item) : Emoji-Icon für Verzeichnisse/Dateien
6
+ * (früher in beiden JS-Dateien dupliziert)
7
+ * - extOf(name) : Extension in lower-case, ohne Punkt
8
+ * - joinPath(a, b), parentOf : simple posix-artige Pfad-Helfer
9
+ * - fmtSize(bytes), fmtDate(v): menschenlesbare Formatierung
10
+ * - isViewable(name) : PDFs, Bilder, Text-artige Dateien
11
+ * - mediaTypeFor(item) : grober MIME-Typ (auch nur für File-Manager)
12
+ * - t(key, params) : i18n-Übersetzung. Katalog wird nach dem
13
+ * Laden asynchron nachgeschoben; solange nur
14
+ * der Key nicht auffindbar ist, gilt der Key
15
+ * selbst als Rückfalltext.
16
+ * - setCatalog(obj) : setzt den i18n-Katalog (aus dem Server-JSON)
17
+ * - loadCatalog(url) : lädt einen JSON-Katalog per fetch()
18
+ *
19
+ * Alles wird an `window.SambaCommon` gehängt. Die Datei ist absichtlich in
20
+ * ES5 gehalten (kein Bundler-Zwang, funktioniert in älteren Browsern).
21
+ */
22
+ (function () {
23
+ "use strict";
24
+
25
+ // ---- i18n-State ---------------------------------------------------------
26
+ var catalog = {};
27
+
28
+ function setCatalog(obj) {
29
+ catalog = obj && typeof obj === "object" ? obj : {};
30
+ }
31
+
32
+ /** Löst {name}-Platzhalter im Muster auf. */
33
+ function interpolate(msg, params) {
34
+ if (!params) return msg;
35
+ return String(msg).replace(/\{(\w+)\}/g, function (_, k) {
36
+ return Object.prototype.hasOwnProperty.call(params, k) ? String(params[k]) : "";
37
+ });
38
+ }
39
+
40
+ /** Übersetzt einen Key. Fällt auf den Key selbst zurück, falls kein Eintrag. */
41
+ function t(key, params) {
42
+ if (Object.prototype.hasOwnProperty.call(catalog, key)) {
43
+ return interpolate(catalog[key], params);
44
+ }
45
+ return interpolate(key, params);
46
+ }
47
+
48
+ /**
49
+ * Lädt den Katalog von der übergebenen URL (z. B. /samba-i18n.json?locale=de).
50
+ * Ergebnis wird gecacht; scheitert der Fetch, bleibt der aktuelle Katalog stehen.
51
+ */
52
+ function loadCatalog(url) {
53
+ return fetch(url, { credentials: "same-origin" })
54
+ .then(function (r) { return r.ok ? r.json() : {}; })
55
+ .then(function (data) { setCatalog(data); return data; })
56
+ .catch(function () { return {}; });
57
+ }
58
+
59
+ // ---- reine Utilities ---------------------------------------------------
60
+ /** Extension in lower-case, ohne führenden Punkt. */
61
+ function extOf(name) {
62
+ var s = String(name || "");
63
+ var dot = s.lastIndexOf(".");
64
+ return dot >= 0 ? s.slice(dot + 1).toLowerCase() : "";
65
+ }
66
+
67
+ /**
68
+ * Einheitliches Icon für Verzeichnisse und Dateien.
69
+ * Die Kategorien decken die häufigsten Office-, Bild-, Archiv-, Audio- und
70
+ * Video-Formate ab. Alles Unbekannte bekommt 📎.
71
+ */
72
+ function iconFor(item) {
73
+ if (item && item.isDir) return "📁";
74
+ var e = extOf(item && item.name);
75
+ if (e === "pdf") return "📄";
76
+ if (["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].indexOf(e) >= 0) return "🖼️";
77
+ if (["doc", "docx", "odt", "rtf", "txt", "md"].indexOf(e) >= 0) return "📝";
78
+ if (["xls", "xlsx", "ods", "csv"].indexOf(e) >= 0) return "📊";
79
+ if (["ppt", "pptx", "odp"].indexOf(e) >= 0) return "📽️";
80
+ if (["zip", "tar", "gz", "7z", "rar"].indexOf(e) >= 0) return "🗜️";
81
+ if (["mp3", "wav", "ogg", "flac", "m4a"].indexOf(e) >= 0) return "🎵";
82
+ if (["mp4", "mkv", "mov", "avi", "webm"].indexOf(e) >= 0) return "🎬";
83
+ return "📎";
84
+ }
85
+
86
+ /** Erlaubt inline-Anzeige im integrierten Viewer (PDF, Bilder, Text). */
87
+ function isViewable(name) {
88
+ var n = String(name || "").toLowerCase();
89
+ return (
90
+ n.endsWith(".pdf") ||
91
+ /\.(png|jpe?g|gif|webp|svg|bmp)$/.test(n) ||
92
+ /\.(txt|md|json|xml|csv|html?)$/.test(n)
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Grober MIME-Typ nur anhand der Extension – ausreichend zum Sortieren und
98
+ * für die "Media type"-Spalte. Der Server liefert für den echten Stream
99
+ * seinen eigenen (genaueren) Content-Type.
100
+ */
101
+ function mediaTypeFor(item) {
102
+ if (item && item.isDir) return "folder";
103
+ var e = extOf(item && item.name);
104
+ var map = {
105
+ pdf: "application/pdf",
106
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
107
+ gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
108
+ txt: "text/plain", md: "text/markdown", csv: "text/csv",
109
+ json: "application/json", xml: "application/xml",
110
+ html: "text/html", htm: "text/html",
111
+ zip: "application/zip", doc: "application/msword",
112
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
113
+ xls: "application/vnd.ms-excel",
114
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
115
+ ppt: "application/vnd.ms-powerpoint",
116
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
117
+ mp3: "audio/mpeg", mp4: "video/mp4", mkv: "video/x-matroska",
118
+ mov: "video/quicktime", avi: "video/x-msvideo",
119
+ };
120
+ return map[e] || (e ? "application/" + e : "application/octet-stream");
121
+ }
122
+
123
+ /** Größe in KiB/MiB/GiB. Kompakter Format-String für Tabellen und Bäume. */
124
+ function fmtSize(n) {
125
+ if (!n || n < 0) return "";
126
+ if (n < 1024) return n + " B";
127
+ var kib = n / 1024;
128
+ if (kib < 1024) return kib.toFixed(kib < 10 ? 1 : 0) + " KiB";
129
+ var mib = kib / 1024;
130
+ if (mib < 1024) return mib.toFixed(mib < 10 ? 1 : 0) + " MiB";
131
+ return (mib / 1024).toFixed(2) + " GiB";
132
+ }
133
+
134
+ /** ISO-artig, ohne Sekunden ("2026-07-08 22:31"). Leere/ungültige Werte → "". */
135
+ function fmtDate(v) {
136
+ if (!v) return "";
137
+ var d = new Date(v);
138
+ if (isNaN(d.getTime())) return "";
139
+ var pad = function (x) { return String(x).padStart(2, "0"); };
140
+ return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
141
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
142
+ }
143
+
144
+ /** Fügt zwei Pfad-Fragmente mit genau einem "/" zusammen. */
145
+ function joinPath(a, b) {
146
+ if (!a) return b;
147
+ if (!b) return a;
148
+ return (a.replace(/\/+$/, "") + "/" + b.replace(/^\/+/, "")).replace(/\/+/g, "/");
149
+ }
150
+
151
+ /** Übergeordneter Pfad. `parentOf("a/b/c")` = "a/b", `parentOf("a")` = "". */
152
+ function parentOf(p) {
153
+ if (!p) return "";
154
+ var i = p.lastIndexOf("/");
155
+ return i < 0 ? "" : p.slice(0, i);
156
+ }
157
+
158
+ // ---- Export -------------------------------------------------------------
159
+ window.SambaCommon = {
160
+ // i18n
161
+ t: t,
162
+ setCatalog: setCatalog,
163
+ loadCatalog: loadCatalog,
164
+ // icons / classification
165
+ iconFor: iconFor,
166
+ isViewable: isViewable,
167
+ mediaTypeFor: mediaTypeFor,
168
+ // strings & paths
169
+ extOf: extOf,
170
+ fmtSize: fmtSize,
171
+ fmtDate: fmtDate,
172
+ joinPath: joinPath,
173
+ parentOf: parentOf,
174
+ };
175
+ })();