margins 0.1.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/links.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Links between files: finding them in markdown, and working out which file
3
+ * each one means.
4
+ *
5
+ * Loaded by the server, to find what links to a file, and by the page, to
6
+ * render and follow links -- one implementation, so the two can never
7
+ * disagree about where [[Some Note]] points. It is written so it runs in
8
+ * both: no requires, no DOM, and it exports itself either way.
9
+ */
10
+ (function (root, factory) {
11
+ if (typeof module === 'object' && module.exports) module.exports = factory();
12
+ else root.MarginsLinks = factory();
13
+ }(typeof self !== 'undefined' ? self : this, function () {
14
+ 'use strict';
15
+
16
+ var MARKDOWN_EXTENSIONS = ['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx'];
17
+
18
+ /**
19
+ * The same markdown with code blanked out, character for character.
20
+ *
21
+ * A link inside a code block is an example of a link, not a link, and a
22
+ * backlinks list that includes every README's "[text](url)" syntax sample
23
+ * is wrong. Blanking rather than removing keeps every line and column where
24
+ * it was, so what is found can still be reported by line number.
25
+ */
26
+ function blankCode(markdown) {
27
+ var lines = String(markdown).split('\n');
28
+ var fence = null;
29
+
30
+ for (var i = 0; i < lines.length; i++) {
31
+ var line = lines[i];
32
+ var open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
33
+
34
+ if (fence) {
35
+ // A fence closes on a run of the same character at least as long.
36
+ if (open && open[1][0] === fence[0] && open[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
37
+ fence = null;
38
+ }
39
+ lines[i] = line.replace(/[^\s]/g, ' ');
40
+ continue;
41
+ }
42
+ if (open) {
43
+ fence = open[1];
44
+ lines[i] = line.replace(/[^\s]/g, ' ');
45
+ continue;
46
+ }
47
+
48
+ // Inline code: a run of backticks closes on the same length run.
49
+ lines[i] = line.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, function (match) {
50
+ return match.replace(/[^\s]/g, ' ');
51
+ });
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+
56
+ /** "Note#Heading|Alias" -> its parts. */
57
+ function parseWikilink(inner) {
58
+ var text = String(inner).trim();
59
+ var alias = null;
60
+ var pipe = text.indexOf('|');
61
+ if (pipe !== -1) {
62
+ alias = text.slice(pipe + 1).trim() || null;
63
+ text = text.slice(0, pipe).trim();
64
+ }
65
+ var heading = null;
66
+ var hash = text.indexOf('#');
67
+ if (hash !== -1) {
68
+ heading = text.slice(hash + 1).trim() || null;
69
+ text = text.slice(0, hash).trim();
70
+ }
71
+ return { target: text, heading: heading, alias: alias };
72
+ }
73
+
74
+ var INLINE_LINK = /(!?)\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*(<[^>\n]*>|[^\s)]+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
75
+ var REFERENCE_DEF = /^ {0,3}\[([^\]]+)\]:\s*(<[^>\n]*>|\S+)/;
76
+ var WIKILINK = /(!?)\[\[([^[\]\n]+?)\]\]/g;
77
+
78
+ /**
79
+ * Every link in a markdown document.
80
+ *
81
+ * @param {string} markdown
82
+ * @returns {{type: 'link'|'image'|'wiki'|'embed', target: string, line: number, text: string}[]}
83
+ * `line` is 1-based; `text` is that whole line, for showing context.
84
+ */
85
+ function extractLinks(markdown) {
86
+ var original = String(markdown).split('\n');
87
+ var blanked = blankCode(markdown).split('\n');
88
+ var links = [];
89
+
90
+ for (var i = 0; i < blanked.length; i++) {
91
+ var line = blanked[i];
92
+ var context = original[i].trim();
93
+ var match;
94
+
95
+ WIKILINK.lastIndex = 0;
96
+ while ((match = WIKILINK.exec(line)) !== null) {
97
+ var parsed = parseWikilink(match[2]);
98
+ if (parsed.target || parsed.heading) {
99
+ links.push({ type: match[1] ? 'embed' : 'wiki', target: match[2].trim(), line: i + 1, text: context });
100
+ }
101
+ }
102
+
103
+ INLINE_LINK.lastIndex = 0;
104
+ while ((match = INLINE_LINK.exec(line)) !== null) {
105
+ var target = match[3];
106
+ if (target.charAt(0) === '<') target = target.slice(1, -1);
107
+ links.push({ type: match[1] ? 'image' : 'link', target: target, line: i + 1, text: context });
108
+ }
109
+
110
+ var def = REFERENCE_DEF.exec(line);
111
+ if (def) {
112
+ var href = def[2];
113
+ if (href.charAt(0) === '<') href = href.slice(1, -1);
114
+ links.push({ type: 'link', target: href, line: i + 1, text: context });
115
+ }
116
+ }
117
+ return links;
118
+ }
119
+
120
+ /** Whether an href leaves the folder: a scheme, or protocol-relative. */
121
+ function isExternal(href) {
122
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.indexOf('//') === 0;
123
+ }
124
+
125
+ function dirname(path) {
126
+ var slash = path.lastIndexOf('/');
127
+ return slash === -1 ? '' : path.slice(0, slash);
128
+ }
129
+
130
+ /** Join and normalise forward-slash segments; null if they climb out of the root. */
131
+ function normalise(parts) {
132
+ var out = [];
133
+ for (var i = 0; i < parts.length; i++) {
134
+ var part = parts[i];
135
+ if (part === '' || part === '.') continue;
136
+ if (part === '..') {
137
+ if (out.length === 0) return null;
138
+ out.pop();
139
+ } else {
140
+ out.push(part);
141
+ }
142
+ }
143
+ return out.join('/');
144
+ }
145
+
146
+ function safeDecode(text) {
147
+ try {
148
+ return decodeURIComponent(text);
149
+ } catch (error) {
150
+ return text;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Where a relative href in `fromPath` points, as a path from the root.
156
+ *
157
+ * @returns {{path: string, fragment: string|null}|null} null for an
158
+ * external link, an empty one, or one that climbs out of the folder
159
+ */
160
+ function resolveHref(fromPath, href) {
161
+ var raw = String(href || '').trim();
162
+ if (!raw || isExternal(raw)) return null;
163
+
164
+ var fragment = null;
165
+ var hash = raw.indexOf('#');
166
+ if (hash !== -1) {
167
+ fragment = safeDecode(raw.slice(hash + 1)) || null;
168
+ raw = raw.slice(0, hash);
169
+ }
170
+ var query = raw.indexOf('?');
171
+ if (query !== -1) raw = raw.slice(0, query);
172
+
173
+ // "#heading" alone is a link within the same file.
174
+ if (raw === '') return { path: fromPath, fragment: fragment };
175
+
176
+ var decoded = safeDecode(raw);
177
+ var base = decoded.charAt(0) === '/' ? [] : dirname(fromPath).split('/');
178
+ var path = normalise(base.concat(decoded.split('/')));
179
+ return path === null ? null : { path: path, fragment: fragment };
180
+ }
181
+
182
+ function stripExtension(name) {
183
+ var lower = name.toLowerCase();
184
+ for (var i = 0; i < MARKDOWN_EXTENSIONS.length; i++) {
185
+ if (lower.slice(-MARKDOWN_EXTENSIONS[i].length) === MARKDOWN_EXTENSIONS[i]) {
186
+ return name.slice(0, -MARKDOWN_EXTENSIONS[i].length);
187
+ }
188
+ }
189
+ return name;
190
+ }
191
+
192
+ function basename(path) {
193
+ var slash = path.lastIndexOf('/');
194
+ return slash === -1 ? path : path.slice(slash + 1);
195
+ }
196
+
197
+ /**
198
+ * Look-up tables for resolving wikilinks against every file in the folder,
199
+ * built once. Resolving by scanning the list each time is fine for one
200
+ * link and quadratic for a backlinks pass over a few thousand notes.
201
+ *
202
+ * @param {string[]} paths every file, as paths from the root
203
+ */
204
+ function buildIndex(paths) {
205
+ var byPath = {};
206
+ var byName = {};
207
+ for (var i = 0; i < paths.length; i++) {
208
+ var path = paths[i];
209
+ byPath[path.toLowerCase()] = path;
210
+
211
+ // Markdown files answer to their name without the extension, which is
212
+ // how people write [[Some Note]]; every file answers to its full name,
213
+ // which is how [[diagram.png]] is written.
214
+ var names = [basename(path).toLowerCase()];
215
+ var bare = stripExtension(basename(path));
216
+ if (bare !== basename(path)) names.push(bare.toLowerCase());
217
+ for (var j = 0; j < names.length; j++) {
218
+ (byName[names[j]] = byName[names[j]] || []).push(path);
219
+ }
220
+ }
221
+ return { byPath: byPath, byName: byName };
222
+ }
223
+
224
+ function depth(path) {
225
+ return path.split('/').length;
226
+ }
227
+
228
+ /**
229
+ * Which file a wikilink means, the way Obsidian decides it: by name, not
230
+ * by path, because notes move and [[Some Note]] should not break when they
231
+ * do.
232
+ *
233
+ * An exact path wins. Otherwise, of the files with that name, the one in
234
+ * the linking file's own folder, then the shallowest, then the first
235
+ * alphabetically -- so the answer is always the same one, not whichever the
236
+ * disk happened to list first.
237
+ *
238
+ * @param {string} target the part before any # or |
239
+ * @param {string} fromPath the linking file
240
+ * @param {{byPath: Object, byName: Object}} index from buildIndex
241
+ * @returns {string|null}
242
+ */
243
+ function resolveWikilink(target, fromPath, index) {
244
+ var wanted = normalise(String(target || '').trim().split('/'));
245
+ if (!wanted) return null;
246
+ var lower = wanted.toLowerCase();
247
+
248
+ // A path, with or without its extension: from the linking file's own
249
+ // folder first, then from the root. Here first, because [[Ideas]] written
250
+ // in notes/ next to notes/Ideas.md means that one, not a root Ideas.md --
251
+ // the same reason a relative link is resolved from its own folder.
252
+ var here = dirname(fromPath);
253
+ var candidates = here ? [(here + '/' + lower).toLowerCase(), lower] : [lower];
254
+ for (var i = 0; i < candidates.length; i++) {
255
+ if (index.byPath[candidates[i]]) return index.byPath[candidates[i]];
256
+ for (var e = 0; e < MARKDOWN_EXTENSIONS.length; e++) {
257
+ var withExt = candidates[i] + MARKDOWN_EXTENSIONS[e];
258
+ if (index.byPath[withExt]) return index.byPath[withExt];
259
+ }
260
+ }
261
+
262
+ // By name. "folder/Note" narrows to files whose path ends that way.
263
+ var name = basename(lower);
264
+ var matches = (index.byName[name] || []).slice();
265
+ if (wanted.indexOf('/') !== -1) {
266
+ matches = matches.filter(function (path) {
267
+ var bare = stripExtension(path).toLowerCase();
268
+ return bare === lower || bare.slice(-(lower.length + 1)) === '/' + lower ||
269
+ path.toLowerCase().slice(-(lower.length + 1)) === '/' + lower;
270
+ });
271
+ }
272
+ if (matches.length === 0) return null;
273
+
274
+ matches.sort(function (a, b) {
275
+ var aHere = dirname(a) === here ? 0 : 1;
276
+ var bHere = dirname(b) === here ? 0 : 1;
277
+ if (aHere !== bHere) return aHere - bHere;
278
+ if (depth(a) !== depth(b)) return depth(a) - depth(b);
279
+ return a < b ? -1 : a > b ? 1 : 0;
280
+ });
281
+ return matches[0];
282
+ }
283
+
284
+ /**
285
+ * A heading's anchor, the way GitHub makes them, so links written for
286
+ * GitHub -- README.md#installation -- land in the same place here.
287
+ */
288
+ function slugify(text) {
289
+ return String(text)
290
+ .trim()
291
+ .toLowerCase()
292
+ .replace(/<[^>]*>/g, '')
293
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
294
+ .replace(/\s/g, '-');
295
+ }
296
+
297
+ function isMarkdownPath(path) {
298
+ return stripExtension(basename(path)) !== basename(path);
299
+ }
300
+
301
+ return {
302
+ MARKDOWN_EXTENSIONS: MARKDOWN_EXTENSIONS,
303
+ blankCode: blankCode,
304
+ buildIndex: buildIndex,
305
+ extractLinks: extractLinks,
306
+ isExternal: isExternal,
307
+ isMarkdownPath: isMarkdownPath,
308
+ normalise: normalise,
309
+ parseWikilink: parseWikilink,
310
+ resolveHref: resolveHref,
311
+ resolveWikilink: resolveWikilink,
312
+ slugify: slugify
313
+ };
314
+ }));
package/lib/paths.js ADDED
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Keeping every file margins touches inside the folder it was opened on.
8
+ *
9
+ * margins writes files, and people open folders they did not write -- a
10
+ * cloned repository, a colleague's notes. So a path from the page is never
11
+ * trusted as a path. It is a list of names below the root, each checked, and
12
+ * the result is checked again after symlinks are followed, because a link
13
+ * inside the folder can point anywhere on the disk.
14
+ */
15
+
16
+ /** A request for something outside the root, or somewhere margins will not go. */
17
+ class ForbiddenPathError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'ForbiddenPathError';
21
+ this.status = 403;
22
+ }
23
+ }
24
+
25
+ /** A path inside the root that names nothing. */
26
+ class NotFoundError extends Error {
27
+ constructor(rel) {
28
+ super(`Nothing at ${rel || 'the root'}`);
29
+ this.name = 'NotFoundError';
30
+ this.status = 404;
31
+ }
32
+ }
33
+
34
+ // Folders that are never listed, searched or written. `.git` is not merely
35
+ // noise: writing `.git/hooks/pre-commit` is running code the next time the
36
+ // user commits, and a page that can write files must not be able to reach it.
37
+ const PROTECTED_DIRS = new Set(['.git', 'node_modules']);
38
+
39
+ /** Whether `candidate` is `root` or somewhere below it. */
40
+ function isInside(root, candidate) {
41
+ const rel = path.relative(root, candidate);
42
+ if (rel === '') return true;
43
+ // A folder may legitimately be called "..notes"; only a leading ".." path
44
+ // segment means "above the root".
45
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
46
+ }
47
+
48
+ /**
49
+ * The names in a page-supplied relative path, or a ForbiddenPathError.
50
+ *
51
+ * Paths from the page are always forward-slash separated and relative to the
52
+ * root. Anything that could climb out -- `..`, an absolute path, a NUL byte,
53
+ * a backslash that Windows would read as a separator -- is refused rather
54
+ * than normalised away, because normalising is how "a/../../etc" becomes
55
+ * a path that looked fine.
56
+ *
57
+ * @param {unknown} rel
58
+ * @returns {string[]}
59
+ */
60
+ function segmentsOf(rel) {
61
+ if (typeof rel !== 'string') throw new ForbiddenPathError('A path must be a string.');
62
+ if (rel.includes('\0')) throw new ForbiddenPathError('A path cannot contain a NUL byte.');
63
+ if (rel.includes('\\')) throw new ForbiddenPathError('Use forward slashes in paths.');
64
+ if (rel.startsWith('/')) throw new ForbiddenPathError('Paths are relative to the folder margins opened.');
65
+
66
+ const segments = rel.split('/').filter(segment => segment !== '' && segment !== '.');
67
+ for (const segment of segments) {
68
+ if (segment === '..') throw new ForbiddenPathError('Paths cannot climb out of the folder.');
69
+ if (PROTECTED_DIRS.has(segment)) {
70
+ throw new ForbiddenPathError(`margins does not open anything inside ${segment}/.`);
71
+ }
72
+ }
73
+ return segments;
74
+ }
75
+
76
+ /** Normalise a page-supplied path to the canonical form used everywhere else. */
77
+ function normaliseRel(rel) {
78
+ return segmentsOf(rel).join('/');
79
+ }
80
+
81
+ /**
82
+ * The real, absolute path of something that exists inside the root.
83
+ *
84
+ * @param {string} realRoot the root, already through fs.realpath
85
+ * @param {string} rel
86
+ * @returns {Promise<string>}
87
+ * @throws {ForbiddenPathError|NotFoundError}
88
+ */
89
+ async function resolveExisting(realRoot, rel) {
90
+ const segments = segmentsOf(rel);
91
+ const target = path.join(realRoot, ...segments);
92
+
93
+ let real;
94
+ try {
95
+ real = await fs.realpath(target);
96
+ } catch (error) {
97
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw new NotFoundError(segments.join('/'));
98
+ throw error;
99
+ }
100
+
101
+ // The check that matters. `target` was inside by construction; `real` is
102
+ // where it actually is once symlinks are followed.
103
+ if (!isInside(realRoot, real)) {
104
+ throw new ForbiddenPathError('That path leads outside the folder through a symlink.');
105
+ }
106
+ return real;
107
+ }
108
+
109
+ /**
110
+ * The absolute path a new file would be created at, inside the root.
111
+ *
112
+ * The file does not exist yet, so its own realpath cannot be checked. Its
113
+ * nearest existing ancestor can, and must be inside the root: otherwise a
114
+ * symlinked folder inside the root is a way to create files anywhere.
115
+ *
116
+ * @param {string} realRoot
117
+ * @param {string} rel
118
+ * @returns {Promise<string>}
119
+ */
120
+ async function resolveNew(realRoot, rel) {
121
+ const segments = segmentsOf(rel);
122
+ if (segments.length === 0) throw new ForbiddenPathError('A new file needs a name.');
123
+
124
+ const target = path.join(realRoot, ...segments);
125
+ let probe = path.dirname(target);
126
+ for (;;) {
127
+ try {
128
+ const real = await fs.realpath(probe);
129
+ if (!isInside(realRoot, real)) {
130
+ throw new ForbiddenPathError('That path leads outside the folder through a symlink.');
131
+ }
132
+ break;
133
+ } catch (error) {
134
+ if (error instanceof ForbiddenPathError) throw error;
135
+ if (error.code !== 'ENOENT') throw error;
136
+ const parent = path.dirname(probe);
137
+ if (parent === probe) throw new ForbiddenPathError('No existing folder above that path.');
138
+ probe = parent;
139
+ }
140
+ }
141
+ return target;
142
+ }
143
+
144
+ /** The forward-slash path of `abs` relative to the root, as the page names it. */
145
+ function toRel(realRoot, abs) {
146
+ return path.relative(realRoot, abs).split(path.sep).join('/');
147
+ }
148
+
149
+ module.exports = {
150
+ ForbiddenPathError,
151
+ NotFoundError,
152
+ PROTECTED_DIRS,
153
+ isInside,
154
+ normaliseRel,
155
+ resolveExisting,
156
+ resolveNew,
157
+ segmentsOf,
158
+ toRel
159
+ };
package/lib/search.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
6
+ const { MAX_TEXT_BYTES, looksBinary } = require('./files');
7
+ const { buildIndex, extractLinks, isMarkdownPath, resolveHref, resolveWikilink } = require('./links');
8
+
9
+ /**
10
+ * Finding text across the folder, and finding what links to a file.
11
+ *
12
+ * Both read every file they look at, every time. There is no index on disk:
13
+ * margins writes nothing into the folder but what you save, and a folder of
14
+ * notes is small enough that reading it is faster than keeping a cache
15
+ * honest.
16
+ */
17
+
18
+ const SEARCH_LIMIT = 200;
19
+ const LINE_PREVIEW = 240;
20
+
21
+ async function readText(abs) {
22
+ try {
23
+ const stat = await fs.stat(abs);
24
+ if (stat.size > MAX_TEXT_BYTES) return null;
25
+ const buffer = await fs.readFile(abs);
26
+ return looksBinary(buffer) ? null : buffer.toString('utf8');
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** A line cut down around a match, so a 5,000-character line does not fill the panel. */
33
+ function preview(line, column, length) {
34
+ if (line.length <= LINE_PREVIEW) return { text: line, start: column };
35
+ const from = Math.max(0, column - 60);
36
+ const text = (from > 0 ? '…' : '') + line.slice(from, from + LINE_PREVIEW) + '…';
37
+ return { text, start: column - from + (from > 0 ? 1 : 0) };
38
+ }
39
+
40
+ /**
41
+ * Case-insensitive search for a phrase across every text file.
42
+ *
43
+ * @param {string} realRoot
44
+ * @param {{path: string}[]} files from walkFiles
45
+ * @param {string} query
46
+ * @returns {Promise<{results: object[], truncated: boolean, filesSearched: number}>}
47
+ */
48
+ async function searchFiles(realRoot, files, query, { limit = SEARCH_LIMIT } = {}) {
49
+ const needle = query.toLowerCase();
50
+ const results = [];
51
+ let filesSearched = 0;
52
+
53
+ // Markdown first: in a mixed folder the notes are what someone searching
54
+ // is looking for, and a cap reached halfway through a lockfile would hide
55
+ // them.
56
+ const ordered = [...files].sort((a, b) => (isMarkdownPath(b.path) ? 1 : 0) - (isMarkdownPath(a.path) ? 1 : 0));
57
+
58
+ for (const file of ordered) {
59
+ if (file.kind === 'image') continue;
60
+ const content = await readText(path.join(realRoot, ...file.path.split('/')));
61
+ if (content === null) continue;
62
+ filesSearched += 1;
63
+
64
+ const lines = content.split('\n');
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const column = lines[i].toLowerCase().indexOf(needle);
67
+ if (column === -1) continue;
68
+
69
+ const shown = preview(lines[i], column, needle.length);
70
+ results.push({ path: file.path, line: i + 1, text: shown.text, start: shown.start, length: needle.length });
71
+ if (results.length >= limit) return { results, truncated: true, filesSearched };
72
+ }
73
+ }
74
+ return { results, truncated: false, filesSearched };
75
+ }
76
+
77
+ /**
78
+ * Every place in a markdown file elsewhere in the folder that links to
79
+ * `target` -- by relative path, as GitHub would follow it, or by
80
+ * [[wikilink]], as Obsidian would.
81
+ *
82
+ * @param {string} realRoot
83
+ * @param {{path: string}[]} files from walkFiles
84
+ * @param {string} target a path from the root
85
+ */
86
+ async function findBacklinks(realRoot, files, target) {
87
+ const index = buildIndex(files.map(file => file.path));
88
+ const backlinks = [];
89
+
90
+ for (const file of files) {
91
+ if (!isMarkdownPath(file.path) || file.path === target) continue;
92
+ const content = await readText(path.join(realRoot, ...file.path.split('/')));
93
+ if (content === null) continue;
94
+
95
+ const seenLines = new Set();
96
+ for (const link of extractLinks(content)) {
97
+ let resolved = null;
98
+ if (link.type === 'wiki' || link.type === 'embed') {
99
+ const inner = link.target.split('|')[0].split('#')[0].trim();
100
+ // [[#Heading]] is a link within the same note, not to another one.
101
+ resolved = inner ? resolveWikilink(inner, file.path, index) : null;
102
+ } else {
103
+ resolved = resolveHref(file.path, link.target)?.path ?? null;
104
+ }
105
+
106
+ // One entry per line: a line linking to the same note twice is one
107
+ // mention of it, not two.
108
+ if (resolved === target && !seenLines.has(link.line)) {
109
+ seenLines.add(link.line);
110
+ backlinks.push({ path: file.path, line: link.line, text: link.text.slice(0, LINE_PREVIEW) });
111
+ }
112
+ }
113
+ }
114
+ return backlinks;
115
+ }
116
+
117
+ module.exports = { SEARCH_LIMIT, findBacklinks, searchFiles };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What stops a web page -- this one, or any other -- from doing more with
5
+ * margins than reading and writing the folder it was opened on, at the
6
+ * request of the person using it.
7
+ *
8
+ * Three different attacks, three different defences, and none of them is
9
+ * sufficient alone:
10
+ *
11
+ * 1. A markdown file in a cloned repository containing script. Rendered
12
+ * naively, it would run in this origin and could call the write API.
13
+ * The page sanitises what it renders; this Content-Security-Policy is the
14
+ * backstop that refuses to run any script margins did not ship itself.
15
+ *
16
+ * 2. Another website, open in the same browser, sending requests to
17
+ * 127.0.0.1. It cannot read the answers (no CORS headers are ever sent),
18
+ * but it can *send* a write. So writes need an Origin that is margins' own.
19
+ *
20
+ * 3. DNS rebinding: a site whose name is re-pointed at 127.0.0.1, so its
21
+ * requests count as same-origin to the browser. The Host header still
22
+ * carries the attacker's name, so requests are only answered for the
23
+ * addresses margins actually listens on.
24
+ */
25
+
26
+ const CONTENT_SECURITY_POLICY = [
27
+ "default-src 'self'",
28
+ "script-src 'self'",
29
+ "style-src 'self'",
30
+ // Remote images are allowed because READMEs are full of badges and a
31
+ // viewer that breaks them looks broken. no-referrer below means they are
32
+ // fetched without saying which file, or which folder, they appeared in.
33
+ "img-src 'self' data: https:",
34
+ "connect-src 'self'",
35
+ "font-src 'self'",
36
+ "object-src 'none'",
37
+ "base-uri 'none'",
38
+ "form-action 'none'",
39
+ "frame-ancestors 'none'"
40
+ ].join('; ');
41
+
42
+ /** Headers on every response. */
43
+ function baseHeaders() {
44
+ return {
45
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
46
+ 'X-Content-Type-Options': 'nosniff',
47
+ 'Referrer-Policy': 'no-referrer',
48
+ 'Cross-Origin-Opener-Policy': 'same-origin',
49
+ 'Cross-Origin-Resource-Policy': 'same-origin',
50
+ 'X-Frame-Options': 'DENY'
51
+ };
52
+ }
53
+
54
+ /**
55
+ * For files served raw -- images, most of all SVG. An SVG is a document that
56
+ * can carry script, and opened directly it would run as margins. `sandbox`
57
+ * gives it an origin of its own with scripts off, so the worst a hostile SVG
58
+ * can do is draw itself.
59
+ */
60
+ const RAW_CONTENT_SECURITY_POLICY = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
61
+
62
+ /** The Host values margins answers to, for the port it is on. */
63
+ function allowedHosts(port) {
64
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
65
+ }
66
+
67
+ /** Refuse anything addressed by another name -- see (3) above. */
68
+ function hostIsAllowed(req, port) {
69
+ return allowedHosts(port).has(String(req.headers.host || '').toLowerCase());
70
+ }
71
+
72
+ /**
73
+ * Whether a request that changes something came from margins' own page --
74
+ * see (2) above. A missing Origin is refused too: browsers send one on every
75
+ * write they make, and only a request crafted outside a browser leaves it
76
+ * out, which is not what this API is for.
77
+ */
78
+ function originIsAllowed(req, port) {
79
+ const origin = String(req.headers.origin || '').toLowerCase();
80
+ return [...allowedHosts(port)].some(host => origin === `http://${host}`);
81
+ }
82
+
83
+ module.exports = {
84
+ CONTENT_SECURITY_POLICY,
85
+ RAW_CONTENT_SECURITY_POLICY,
86
+ baseHeaders,
87
+ hostIsAllowed,
88
+ originIsAllowed
89
+ };