@helping-ai-workflow/md2doc 3.0.1 → 3.2.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/lib/editor/asset.js +235 -0
- package/lib/editor/client.js +2017 -85
- package/lib/editor/docsource.js +105 -0
- package/lib/editor/paste-md.js +275 -0
- package/lib/editor/patchmap.js +83 -0
- package/lib/editor/server.js +195 -3
- package/lib/editor/table-md.js +66 -34
- package/lib/editor/toolbar-model.js +160 -0
- package/lib/md2doc.js +176 -4
- package/package.json +3 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* UMD, same shape as convert-md.js: `require`-able in node for the unit
|
|
3
|
+
tests, and injected into the editor page as `window.md2docAsset`
|
|
4
|
+
(lib/editor/server.js) for the parts the client UI needs (sanitizeName /
|
|
5
|
+
extFor / uniqueName / relPath). client.js is inlined into the page as a
|
|
6
|
+
plain <script>, not bundled, so a bare top-level require(...) here would
|
|
7
|
+
be an undefined identifier in the browser and take down the whole
|
|
8
|
+
injected script -- see the comment at the top of convert-md.js.
|
|
9
|
+
|
|
10
|
+
isWithin is NODE/SERVER ONLY: the browser side never calls it (there is
|
|
11
|
+
no filesystem on that side to check containment against; only
|
|
12
|
+
lib/editor/server.js, guarding the real write, calls it). Even so it does
|
|
13
|
+
NOT `require('path')` -- per the ruling for this module, every function
|
|
14
|
+
here is plain string arithmetic, so the file stays safe to inject
|
|
15
|
+
unconditionally regardless of which functions a given caller uses. */
|
|
16
|
+
(function (root, factory) {
|
|
17
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
18
|
+
else root.md2docAsset = factory();
|
|
19
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
20
|
+
|
|
21
|
+
// The image-drop endpoint accepts exactly these encoded raster formats.
|
|
22
|
+
// image/svg+xml is deliberately NOT here: an SVG file can carry an inline
|
|
23
|
+
// <script>/<foreignObject> payload, and this whitelist is the only gate
|
|
24
|
+
// between an attacker-controlled MIME string and a file written to disk --
|
|
25
|
+
// widening it to vector formats trades a decode convenience for a stored-XSS
|
|
26
|
+
// vector, which is not a trade this endpoint gets to make silently.
|
|
27
|
+
var EXT_FOR_MIME = {
|
|
28
|
+
'image/png': '.png',
|
|
29
|
+
'image/jpeg': '.jpg',
|
|
30
|
+
'image/gif': '.gif',
|
|
31
|
+
'image/webp': '.webp',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// A stem is "reserved" -- unusable, must fall back -- if it is exactly '.',
|
|
35
|
+
// exactly '..', or empty. This check is re-run after EVERY transformation
|
|
36
|
+
// that can change the string, not just once at the top: fix-round-1 found
|
|
37
|
+
// that the extension-stripping slice below can manufacture a fresh '..' out
|
|
38
|
+
// of an input the top-of-function check never saw (`'...'` -> base `'...'`,
|
|
39
|
+
// which is not '.'/'..'/'' so it passes unmodified, then
|
|
40
|
+
// `stem = base.slice(0, base.lastIndexOf('.'))` = `'..'`, returned
|
|
41
|
+
// unchecked). The function whose entire job is rejecting '..' must not be
|
|
42
|
+
// able to manufacture one partway through its own body.
|
|
43
|
+
function isReservedStem(s) {
|
|
44
|
+
return s === '.' || s === '..' || s === '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The longest extension this module ever attaches (see EXT_FOR_MIME) is
|
|
48
|
+
// '.webp' at 5 bytes; reserving that much headroom out of the 255-byte
|
|
49
|
+
// filename budget guarantees stem + extension never exceeds it, without
|
|
50
|
+
// this function needing to know which mime the caller will pick.
|
|
51
|
+
var MAX_NAME_BYTES = 255;
|
|
52
|
+
var MAX_EXT_BYTES = Object.keys(EXT_FOR_MIME).reduce(function (m, k) {
|
|
53
|
+
return Math.max(m, EXT_FOR_MIME[k].length);
|
|
54
|
+
}, 0);
|
|
55
|
+
var MAX_STEM_BYTES = MAX_NAME_BYTES - MAX_EXT_BYTES;
|
|
56
|
+
|
|
57
|
+
// UTF-8 byte length of one Unicode CODE POINT (not a UTF-16 code unit).
|
|
58
|
+
function utf8ByteLen(codePoint) {
|
|
59
|
+
if (codePoint <= 0x7f) return 1;
|
|
60
|
+
if (codePoint <= 0x7ff) return 2;
|
|
61
|
+
if (codePoint <= 0xffff) return 3;
|
|
62
|
+
return 4;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Truncates `s` to at most `maxBytes` UTF-8 bytes without splitting a
|
|
66
|
+
// Unicode code point (iterates via Array.from, which walks code points, not
|
|
67
|
+
// UTF-16 code units -- load-bearing for CJK/astral characters, both
|
|
68
|
+
// realistic in a pasted screenshot filename). No Buffer / TextEncoder
|
|
69
|
+
// dependency, so this stays safe to run in the browser build of this
|
|
70
|
+
// module too, same as everything else in this file.
|
|
71
|
+
function truncateUtf8(s, maxBytes) {
|
|
72
|
+
var out = '';
|
|
73
|
+
var used = 0;
|
|
74
|
+
var chars = Array.from(s);
|
|
75
|
+
for (var i = 0; i < chars.length; i++) {
|
|
76
|
+
var len = utf8ByteLen(chars[i].codePointAt(0));
|
|
77
|
+
if (used + len > maxBytes) break;
|
|
78
|
+
out += chars[i];
|
|
79
|
+
used += len;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Only the last path segment survives, and only its stem (the caller
|
|
85
|
+
// re-attaches whatever extFor(mime) decides -- see extFor below -- so
|
|
86
|
+
// whatever extension the ORIGINAL name carried, spoofed or not, is
|
|
87
|
+
// discarded here, not preserved).
|
|
88
|
+
//
|
|
89
|
+
// Three separator families are stripped, not just '/':
|
|
90
|
+
// '/' -- the traversal vector this endpoint obviously has to defend
|
|
91
|
+
// ('../../etc/passwd', 'a/b.png').
|
|
92
|
+
// '\\' -- Windows is a supported target for this tool even though this
|
|
93
|
+
// process may run on Linux; 'C:\Windows\x.png' must not survive
|
|
94
|
+
// as a literal filename component either.
|
|
95
|
+
// ':' -- NTFS Alternate Data Stream syntax ('x.png:evil' writes into a
|
|
96
|
+
// stream named 'evil' *on* the real file 'x.png') and the drive
|
|
97
|
+
// letter ('C:'). Both are neutralized by taking only what
|
|
98
|
+
// precedes the first colon found in the segment that already
|
|
99
|
+
// survived the '/' and '\\' split.
|
|
100
|
+
//
|
|
101
|
+
// The result is also capped to MAX_STEM_BYTES (UTF-8 bytes), truncating
|
|
102
|
+
// only the stem -- there is no extension left to truncate at this point,
|
|
103
|
+
// it has already been stripped -- so a long pasted-screenshot filename
|
|
104
|
+
// cannot reach fs.writeFileSync and fail with ENAMETOOLONG.
|
|
105
|
+
function sanitizeName(filename) {
|
|
106
|
+
var s = String(filename === null || filename === undefined ? '' : filename);
|
|
107
|
+
s = s.split('\\').join('/');
|
|
108
|
+
var parts = s.split('/');
|
|
109
|
+
var base = parts[parts.length - 1];
|
|
110
|
+
var colonIdx = base.indexOf(':');
|
|
111
|
+
if (colonIdx !== -1) base = base.slice(0, colonIdx);
|
|
112
|
+
// '..' and '.' are meaningful only as path SEGMENTS; once nothing but the
|
|
113
|
+
// last segment is left, they carry no traversal power any more, but they
|
|
114
|
+
// are still not usable file stems, so map them (and the empty string, the
|
|
115
|
+
// "no filename at all" case) onto one defined fallback.
|
|
116
|
+
if (isReservedStem(base)) base = 'file';
|
|
117
|
+
// Strip the extension: the last '.' in the segment, as long as it is not
|
|
118
|
+
// the segment's very first character (that makes '.png' a dotfile stem,
|
|
119
|
+
// not an extension marker with nothing in front of it -- still a defined,
|
|
120
|
+
// deterministic result, just not a stripped one).
|
|
121
|
+
var dot = base.lastIndexOf('.');
|
|
122
|
+
var stem = dot > 0 ? base.slice(0, dot) : base;
|
|
123
|
+
// Re-validate: this slice can manufacture '.', '..' or '' out of an
|
|
124
|
+
// all-dots (or dot-prefixed) `base` that the check above never caught.
|
|
125
|
+
if (isReservedStem(stem)) stem = 'file';
|
|
126
|
+
stem = truncateUtf8(stem, MAX_STEM_BYTES);
|
|
127
|
+
// Re-validate again: belt-and-suspenders against a future MAX_STEM_BYTES
|
|
128
|
+
// shrinking below 2 and cutting a legitimate '..foo'-shaped stem down to
|
|
129
|
+
// a bare '..'. Not reachable at the current 250-byte budget, but the
|
|
130
|
+
// point of this whole fix round is that "not reachable today" is not the
|
|
131
|
+
// same guarantee as "provably never produces '.'/'..'/''".
|
|
132
|
+
if (isReservedStem(stem)) stem = 'file';
|
|
133
|
+
return stem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// mime -> extension, or null when it is outside EXT_FOR_MIME. Never guesses
|
|
137
|
+
// from the filename -- that is the whole point of gating on the whitelist.
|
|
138
|
+
function extFor(mime) {
|
|
139
|
+
var key = String(mime === null || mime === undefined ? '' : mime);
|
|
140
|
+
return Object.prototype.hasOwnProperty.call(EXT_FOR_MIME, key) ? EXT_FOR_MIME[key] : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// CONTRACT (fix-round-1 ruling): `base` is a FULL FILENAME, extension
|
|
144
|
+
// included (e.g. 'a.png'), and `existing` is a raw directory listing of the
|
|
145
|
+
// same shape (e.g. fs.readdir()'s output -- also full filenames). The
|
|
146
|
+
// integration task composes the candidate as
|
|
147
|
+
// `uniqueName(sanitizeName(name) + extFor(mime), existing)` BEFORE calling
|
|
148
|
+
// this function, not `uniqueName(stem, existing)` with the extension
|
|
149
|
+
// appended after: a bare stem checked against an extension-bearing listing
|
|
150
|
+
// silently defeats collision detection ('x' !== 'x.png' as far as
|
|
151
|
+
// indexOf() is concerned, even though 'x.png' really is taken). This
|
|
152
|
+
// function's own contract does not change to accommodate that -- it stays
|
|
153
|
+
// the plain, already-tested extension-aware suffixer pinned by
|
|
154
|
+
// uniqueName('a.png', ['a.png','a-2.png']) -> 'a-3.png'; only the caller's
|
|
155
|
+
// composition order does.
|
|
156
|
+
//
|
|
157
|
+
// Appends '-2', '-3', ... immediately before the extension until `base`
|
|
158
|
+
// (or a suffixed variant of it) is absent from `existing`.
|
|
159
|
+
function uniqueName(base, existing) {
|
|
160
|
+
var list = Array.isArray(existing) ? existing : [];
|
|
161
|
+
var s = String(base === null || base === undefined ? '' : base);
|
|
162
|
+
var dot = s.lastIndexOf('.');
|
|
163
|
+
var stem = dot > 0 ? s.slice(0, dot) : s;
|
|
164
|
+
var ext = dot > 0 ? s.slice(dot) : '';
|
|
165
|
+
if (list.indexOf(s) === -1) return s;
|
|
166
|
+
var n = 2;
|
|
167
|
+
for (;;) {
|
|
168
|
+
var candidate = stem + '-' + n + ext;
|
|
169
|
+
if (list.indexOf(candidate) === -1) return candidate;
|
|
170
|
+
n += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pure formatter: the caller is expected to have already run `name` through
|
|
175
|
+
// sanitizeName / extFor / uniqueName. relPath performs no validation of its
|
|
176
|
+
// own -- it is not the safety boundary; sanitizeName and isWithin are.
|
|
177
|
+
function relPath(name) {
|
|
178
|
+
return 'assets/' + String(name === null || name === undefined ? '' : name);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// POSIX-style '.'/'..' resolution over a '/'-joined string, without pulling
|
|
182
|
+
// in node's `path` module (see the file-header comment for why). Good
|
|
183
|
+
// enough for isWithin's job: `dir` and `target` are both server-side
|
|
184
|
+
// absolute filesystem paths built by lib/editor/server.js, not user input
|
|
185
|
+
// carrying Windows separators -- sanitizeName is what stands between user
|
|
186
|
+
// input and either argument here.
|
|
187
|
+
function normalizeSegments(p) {
|
|
188
|
+
var s = String(p === null || p === undefined ? '' : p);
|
|
189
|
+
var abs = s.indexOf('/') === 0;
|
|
190
|
+
var parts = s.split('/');
|
|
191
|
+
var out = [];
|
|
192
|
+
for (var i = 0; i < parts.length; i++) {
|
|
193
|
+
var seg = parts[i];
|
|
194
|
+
if (seg === '' || seg === '.') continue;
|
|
195
|
+
if (seg === '..') {
|
|
196
|
+
if (out.length && out[out.length - 1] !== '..') out.pop();
|
|
197
|
+
else if (!abs) out.push('..');
|
|
198
|
+
// an absolute path's '..' past the root has nowhere to go; drop it
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
out.push(seg);
|
|
202
|
+
}
|
|
203
|
+
return (abs ? '/' : '') + out.join('/');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// true iff `target`, once '.'/'..' is resolved away, is a strict descendant
|
|
207
|
+
// of `dir` (also resolved). This is the third and final boundary: even if
|
|
208
|
+
// sanitizeName's basename-only contract were somehow bypassed upstream, a
|
|
209
|
+
// resolved target that escapes `dir` -- or merely shares its string prefix
|
|
210
|
+
// without a path separator between them ('/x' vs '/xy/...') -- is refused.
|
|
211
|
+
// `target === dir` is also refused: every real caller appends a filename to
|
|
212
|
+
// `dir` before checking, so equality is never the legitimate case, and
|
|
213
|
+
// accepting it would just be a hole with no corresponding valid use.
|
|
214
|
+
//
|
|
215
|
+
// CONTRACT (fix-round-1 ruling, binding): `dir` and `target` MUST already
|
|
216
|
+
// be '/'-normalized POSIX paths when passed in. This function does not
|
|
217
|
+
// understand '\\' as a separator -- ruled POSIX-only on purpose, so this
|
|
218
|
+
// file keeps avoiding node's `path` module; normalizing a native-Windows
|
|
219
|
+
// path to '/' before calling this is the CALLER's job (the integration
|
|
220
|
+
// task's server-side boundary, not this module). A '\\'-separated `target`
|
|
221
|
+
// is not silently accepted as "within" -- normalizeSegments treats the
|
|
222
|
+
// whole backslash-joined string as one path segment, so it can never match
|
|
223
|
+
// the '/'-joined `dir` prefix, and isWithin FAILS CLOSED (returns false)
|
|
224
|
+
// rather than wrongly permitting a traversal. Confirmed by the fix-round-1
|
|
225
|
+
// review: this is a functional bug on native Windows (a legitimate target
|
|
226
|
+
// gets refused), not a security hole.
|
|
227
|
+
function isWithin(dir, target) {
|
|
228
|
+
var d = normalizeSegments(dir);
|
|
229
|
+
var t = normalizeSegments(target);
|
|
230
|
+
if (t === d) return false;
|
|
231
|
+
return t.indexOf(d + '/') === 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { sanitizeName: sanitizeName, extFor: extFor, uniqueName: uniqueName, relPath: relPath, isWithin: isWithin };
|
|
235
|
+
});
|