@magland/mochi 0.3.1 → 0.4.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/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.repoNeedsGc = repoNeedsGc;
|
|
37
|
+
exports.gcSweep = gcSweep;
|
|
38
|
+
exports.runsSweep = runsSweep;
|
|
39
|
+
exports.firstSweepDelay = firstSweepDelay;
|
|
40
|
+
exports.startMaintenance = startMaintenance;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const artifacts_1 = require("./ci/artifacts");
|
|
44
|
+
const runs_1 = require("./ci/runs");
|
|
45
|
+
const config_1 = require("./config");
|
|
46
|
+
const git_1 = require("./git");
|
|
47
|
+
const layout_1 = require("./layout");
|
|
48
|
+
const scan_1 = require("./scan");
|
|
49
|
+
// Periodic upkeep: `git gc` over the repositories in the vault, and CI
|
|
50
|
+
// retention over their run history (runsSweep, below).
|
|
51
|
+
//
|
|
52
|
+
// Git adds objects and never removes them. A force push, a branch deleted, a
|
|
53
|
+
// history rewritten: each one leaves the commits, trees, and blobs it abandoned
|
|
54
|
+
// in the object store, unreachable from any ref, still readable by anyone who
|
|
55
|
+
// knows the hash, and still counted against the disk. Only a garbage collection
|
|
56
|
+
// walks out from the refs and drops what it could not reach.
|
|
57
|
+
//
|
|
58
|
+
// Nothing else here runs one. Git's own automatic pass is tuned for a working
|
|
59
|
+
// copy accumulating loose objects, and a bare repository on a forge receives
|
|
60
|
+
// packs instead, so in practice it never fires. Without this sweep a vault
|
|
61
|
+
// keeps every mistake anyone has ever pushed to it.
|
|
62
|
+
//
|
|
63
|
+
// What makes a sweep safe to run beside live traffic is the grace period. A
|
|
64
|
+
// push uploads its objects before it moves the branch that will make them
|
|
65
|
+
// reachable, so for a moment those objects look exactly like abandoned ones; a
|
|
66
|
+
// collection that spared nothing could delete a push in flight. PRUNE_GRACE
|
|
67
|
+
// keeps this one away from anything recent, which is also why it is not the
|
|
68
|
+
// answer to "I pushed a secret, get it out now": that wants --prune=now, and
|
|
69
|
+
// wants to know that nobody is mid-push.
|
|
70
|
+
/** How often to sweep, measured from the end of the last sweep. */
|
|
71
|
+
const SWEEP_MS = 6 * 60 * 60 * 1000;
|
|
72
|
+
/**
|
|
73
|
+
* The soonest a sweep runs after the server starts, when one is due. Long
|
|
74
|
+
* enough that the request which woke a sleeping machine is answered before the
|
|
75
|
+
* sweep competes with it for the disk, and well inside the few idle minutes
|
|
76
|
+
* after which a scale-to-zero host stops the machine again.
|
|
77
|
+
*/
|
|
78
|
+
const MIN_SWEEP_DELAY_MS = 60 * 1000;
|
|
79
|
+
/**
|
|
80
|
+
* Written at the vault root when a sweep finishes. Its mtime is what the next
|
|
81
|
+
* sweep is due from, so the schedule survives a restart: a vault on a host
|
|
82
|
+
* that stops it after a few idle minutes (Fly's autostop, say) never stays up
|
|
83
|
+
* for a timer counted from process start, and would otherwise never sweep at
|
|
84
|
+
* all. It is not vault state and backups leave it out (see ROOT_FILES in
|
|
85
|
+
* src/api/backup.ts); losing it costs one sweep sooner than needed.
|
|
86
|
+
*/
|
|
87
|
+
const SWEEP_STAMP = 'mochi-last-sweep';
|
|
88
|
+
/**
|
|
89
|
+
* Objects unreachable but newer than this are kept. Two days is far longer than
|
|
90
|
+
* any push or clone this server will serve, and it is the whole safety
|
|
91
|
+
* argument for running gc unattended, so it is deliberately generous.
|
|
92
|
+
*/
|
|
93
|
+
const PRUNE_GRACE = '2.days.ago';
|
|
94
|
+
/** A repository still collecting after this long is left to the next sweep. */
|
|
95
|
+
const GC_TIMEOUT_MS = 30 * 60 * 1000;
|
|
96
|
+
/**
|
|
97
|
+
* Written in the repository directory after a successful collection. Its mtime
|
|
98
|
+
* is the only state the sweep keeps, and losing it costs one extra gc.
|
|
99
|
+
*/
|
|
100
|
+
const STAMP = 'mochi-last-gc';
|
|
101
|
+
// Queue depth, interval, and grace are constants rather than settings, as the
|
|
102
|
+
// gate queues in src/limit.ts are: nobody tunes them without reading this
|
|
103
|
+
// comment, and they can be promoted to config.json if anyone asks.
|
|
104
|
+
function mtimeOf(file) {
|
|
105
|
+
try {
|
|
106
|
+
return fs.statSync(file).mtimeMs;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** The newest mtime anywhere under dir, which is only walked for `refs`. */
|
|
113
|
+
function newestUnder(dir) {
|
|
114
|
+
let newest = 0;
|
|
115
|
+
const visit = (p) => {
|
|
116
|
+
let stat;
|
|
117
|
+
try {
|
|
118
|
+
stat = fs.statSync(p);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (stat.mtimeMs > newest)
|
|
124
|
+
newest = stat.mtimeMs;
|
|
125
|
+
if (!stat.isDirectory())
|
|
126
|
+
return;
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = fs.readdirSync(p, { withFileTypes: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const entry of entries)
|
|
135
|
+
visit(path.join(p, entry.name));
|
|
136
|
+
};
|
|
137
|
+
visit(dir);
|
|
138
|
+
return newest;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether anything has happened to this repository since it was last collected.
|
|
142
|
+
*
|
|
143
|
+
* Loose refs are walked, since `refs` holds one small file per branch and tag.
|
|
144
|
+
* The object store is not: `objects` and `objects/pack` change whenever a push
|
|
145
|
+
* lands, which is all this needs to know, and walking a real repository's
|
|
146
|
+
* fanout directories on every sweep would cost more than the gc it is deciding
|
|
147
|
+
* whether to run.
|
|
148
|
+
*
|
|
149
|
+
* Exported for the tests, which build a repository shape without a git binary.
|
|
150
|
+
*/
|
|
151
|
+
function repoNeedsGc(repoDir) {
|
|
152
|
+
const last = mtimeOf(path.join(repoDir, STAMP));
|
|
153
|
+
// Never collected: collect it, whatever its mtimes say.
|
|
154
|
+
if (last === 0)
|
|
155
|
+
return true;
|
|
156
|
+
const changed = Math.max(newestUnder(path.join(repoDir, 'refs')), mtimeOf(path.join(repoDir, 'packed-refs')), mtimeOf(path.join(repoDir, 'objects')), mtimeOf(path.join(repoDir, 'objects', 'pack')));
|
|
157
|
+
return changed > last;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Collect every repository that has changed since its last collection, one at a
|
|
161
|
+
* time, and return the names of the ones collected.
|
|
162
|
+
*
|
|
163
|
+
* Serial on purpose: a gc is CPU and disk bound, and a vault whose repositories
|
|
164
|
+
* all changed should spend one core on catching up rather than every core.
|
|
165
|
+
*/
|
|
166
|
+
async function gcSweep(root) {
|
|
167
|
+
const collected = [];
|
|
168
|
+
for (const { name: collection } of (0, scan_1.listCollections)(root)) {
|
|
169
|
+
for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
|
|
170
|
+
const dir = (0, layout_1.repoPath)(root, collection, dirName);
|
|
171
|
+
const full = `${collection}/${(0, scan_1.displayName)(dirName)}`;
|
|
172
|
+
if (!repoNeedsGc(dir))
|
|
173
|
+
continue;
|
|
174
|
+
try {
|
|
175
|
+
await (0, git_1.execGit)(dir, ['gc', '--quiet', `--prune=${PRUNE_GRACE}`], { timeoutMs: GC_TIMEOUT_MS });
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
// A gc that fails is not an outage, and the commonest reason is that
|
|
179
|
+
// another one holds the repository's gc.pid lock. No stamp is written,
|
|
180
|
+
// so the next sweep tries again.
|
|
181
|
+
console.error(`gc ${full}: ${e instanceof Error ? e.message : String(e)}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// After the gc, so that the work the gc itself did to `objects` and
|
|
185
|
+
// `packed-refs` does not read as a reason to collect again.
|
|
186
|
+
try {
|
|
187
|
+
fs.writeFileSync(path.join(dir, STAMP), '');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Without a stamp the repository is collected again next sweep, which
|
|
191
|
+
// is wasteful and harmless.
|
|
192
|
+
}
|
|
193
|
+
collected.push(full);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return collected;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Apply CI retention to every repository, and delete the site artifacts that
|
|
200
|
+
* completed runs have already deployed. Returns the bytes of deployed
|
|
201
|
+
* artifacts removed.
|
|
202
|
+
*
|
|
203
|
+
* Retention is otherwise applied only when a repository starts a run, so a
|
|
204
|
+
* repository that has gone quiet keeps whatever it had, and lowering the run
|
|
205
|
+
* count in config.json would reach it never. This is also what clears the
|
|
206
|
+
* site archives kept by runs from before deploys deleted their own.
|
|
207
|
+
*/
|
|
208
|
+
function runsSweep(root) {
|
|
209
|
+
const retain = (0, config_1.loadConfig)(root).ci;
|
|
210
|
+
let freed = 0;
|
|
211
|
+
for (const { name: collection } of (0, scan_1.listCollections)(root)) {
|
|
212
|
+
for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
|
|
213
|
+
const repo = (0, scan_1.displayName)(dirName);
|
|
214
|
+
try {
|
|
215
|
+
freed += (0, artifacts_1.pruneDeployedArtifacts)(root, collection, repo);
|
|
216
|
+
(0, runs_1.pruneRuns)(root, collection, repo, retain);
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
console.error(`runs ${collection}/${repo}: ${e instanceof Error ? e.message : String(e)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return freed;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* How long after starting to run the first sweep, given when the last one
|
|
227
|
+
* finished (0 for never). A sweep that is due runs MIN_SWEEP_DELAY_MS after
|
|
228
|
+
* start; one that is not waits out the rest of SWEEP_MS, so restarting a
|
|
229
|
+
* vault neither costs a sweep nor postpones one. A stamp from the future (a
|
|
230
|
+
* clock that moved) waits at most one full interval.
|
|
231
|
+
*/
|
|
232
|
+
function firstSweepDelay(lastSweepMs, now) {
|
|
233
|
+
const due = lastSweepMs + SWEEP_MS - now;
|
|
234
|
+
return Math.min(SWEEP_MS, Math.max(MIN_SWEEP_DELAY_MS, due));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Start the sweep. Returns a function that stops it, which the tests use;
|
|
238
|
+
* the server itself runs it for as long as it runs.
|
|
239
|
+
*
|
|
240
|
+
* The timer is unref'd, so a sweep pending is never the reason a process stays
|
|
241
|
+
* alive, and a sweep already running is never joined by a second one. A sweep
|
|
242
|
+
* cut short by the machine stopping writes no stamp, so it is due again on the
|
|
243
|
+
* next start; the per-repository gc stamps mean it resumes rather than repeats.
|
|
244
|
+
*/
|
|
245
|
+
function startMaintenance(root) {
|
|
246
|
+
let running = false;
|
|
247
|
+
let timer = null;
|
|
248
|
+
let stopped = false;
|
|
249
|
+
const stampFile = path.join(root, SWEEP_STAMP);
|
|
250
|
+
const schedule = (delay) => {
|
|
251
|
+
if (stopped)
|
|
252
|
+
return;
|
|
253
|
+
timer = setTimeout(() => void tick(), delay);
|
|
254
|
+
timer.unref();
|
|
255
|
+
};
|
|
256
|
+
const tick = async () => {
|
|
257
|
+
if (running)
|
|
258
|
+
return;
|
|
259
|
+
running = true;
|
|
260
|
+
try {
|
|
261
|
+
const freed = runsSweep(root);
|
|
262
|
+
if (freed > 0)
|
|
263
|
+
console.log(`runs: removed ${Math.round(freed / 1048576)} MB of deployed site artifacts`);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
console.error(`runs sweep: ${e instanceof Error ? e.message : String(e)}`);
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const collected = await gcSweep(root);
|
|
270
|
+
if (collected.length > 0) {
|
|
271
|
+
console.log(`gc: collected ${collected.length} repositor${collected.length === 1 ? 'y' : 'ies'}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
console.error(`gc sweep: ${e instanceof Error ? e.message : String(e)}`);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
running = false;
|
|
279
|
+
}
|
|
280
|
+
// Written even when a part of the sweep failed: those failures are logged,
|
|
281
|
+
// and retrying them on every start of a machine that wakes many times a
|
|
282
|
+
// day would turn one bad repository into constant work.
|
|
283
|
+
try {
|
|
284
|
+
fs.writeFileSync(stampFile, '');
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
console.error(`sweep stamp: ${e instanceof Error ? e.message : String(e)}`);
|
|
288
|
+
}
|
|
289
|
+
schedule(SWEEP_MS);
|
|
290
|
+
};
|
|
291
|
+
schedule(firstSweepDelay(mtimeOf(stampFile), Date.now()));
|
|
292
|
+
return () => {
|
|
293
|
+
stopped = true;
|
|
294
|
+
if (timer)
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
};
|
|
297
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -54,6 +54,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
54
54
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
55
55
|
};
|
|
56
56
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
|
+
exports.USER_CONTENT_PREFIX = void 0;
|
|
57
58
|
exports.isMarkdownFile = isMarkdownFile;
|
|
58
59
|
exports.renderMarkdown = renderMarkdown;
|
|
59
60
|
const crypto = __importStar(require("crypto"));
|
|
@@ -71,6 +72,17 @@ const views_1 = require("./views");
|
|
|
71
72
|
// of a longer word, and a commit id must contain at least one letter, so that
|
|
72
73
|
// a plain number written in passing is not mistaken for one.
|
|
73
74
|
const CROSS_REF = /(?<![\w#/-])(?:#(\d{1,9})|([0-9a-f]{7,40}))(?![\w-])/g;
|
|
75
|
+
// GitHub's @mentions: an @ followed by something shaped like a username (see
|
|
76
|
+
// isValidUserName in src/scan.ts). The pattern refuses an @ that ends a word,
|
|
77
|
+
// so an email address written in passing is not half-linked. The match is
|
|
78
|
+
// greedy over the username characters; trailing punctuation that keeps the
|
|
79
|
+
// name from resolving is peeled off afterwards, so "@bob." mentions bob.
|
|
80
|
+
const MENTION = /(?<![\w@.-])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
|
|
81
|
+
// A channel named in passing: # and a channel's name, lowercase letters,
|
|
82
|
+
// digits, and hyphens. As with a mention, a # inside a word (a URL's
|
|
83
|
+
// fragment, an HTML entity) is refused, and a trailing hyphen that keeps the
|
|
84
|
+
// name from resolving is peeled off.
|
|
85
|
+
const CHANNEL_REF = /(?<![\w#&/-])#([a-z0-9][a-z0-9-]*)/g;
|
|
74
86
|
function isMarkdownFile(filename) {
|
|
75
87
|
const base = filename.split('/').pop() ?? filename;
|
|
76
88
|
return /\.(md|markdown)$/i.test(base);
|
|
@@ -124,9 +136,15 @@ const ALLOWED_TAGS = [
|
|
|
124
136
|
'abbr', 'cite', 'q', 'input',
|
|
125
137
|
];
|
|
126
138
|
// No `style` anywhere: an inline style on document content could cover the
|
|
127
|
-
// rest of the page, which is a phishing surface even without scripting.
|
|
139
|
+
// rest of the page, which is a phishing surface even without scripting. No
|
|
140
|
+
// `class` either, for the same reason by another route: the stylesheet is
|
|
141
|
+
// the interface's own, so `<div class="topbar">` in a README would stick to
|
|
142
|
+
// the top of the page over the real one, with a Sign in button of its own.
|
|
143
|
+
// The classes the renderer itself emits are allowed by name below, and only
|
|
144
|
+
// on the elements it emits them on; a class an author wrote is dropped, as
|
|
145
|
+
// GitHub drops it. `id` stays, prefixed (see userContentId).
|
|
128
146
|
const ALLOWED_ATTRS = {
|
|
129
|
-
'*': ['id', '
|
|
147
|
+
'*': ['id', 'title', 'align', 'dir', 'lang', 'role', 'aria-label', 'aria-hidden'],
|
|
130
148
|
a: ['href', 'name', 'target', 'rel'],
|
|
131
149
|
img: ['src', 'alt', 'width', 'height', 'loading'],
|
|
132
150
|
th: ['colspan', 'rowspan', 'scope', 'valign'],
|
|
@@ -138,19 +156,56 @@ const ALLOWED_ATTRS = {
|
|
|
138
156
|
details: ['open'],
|
|
139
157
|
input: ['type', 'checked', 'disabled'],
|
|
140
158
|
};
|
|
159
|
+
// What the renderer puts a class on, and which. Anything else is an author's
|
|
160
|
+
// and is dropped; see ALLOWED_ATTRS. The footnote entries are
|
|
161
|
+
// markdown-it-footnote's own markup.
|
|
162
|
+
const ALLOWED_CLASSES = {
|
|
163
|
+
code: [/^language-[A-Za-z0-9_+#.-]+$/, 'math-error'],
|
|
164
|
+
div: ['code-block', 'math-block'],
|
|
165
|
+
blockquote: ['alert', /^alert-(note|tip|important|warning|caution)$/],
|
|
166
|
+
p: ['alert-title'],
|
|
167
|
+
li: ['task-item', 'footnote-item'],
|
|
168
|
+
a: ['heading-anchor', 'footnote-backref'],
|
|
169
|
+
sup: ['footnote-ref'],
|
|
170
|
+
hr: ['footnotes-sep'],
|
|
171
|
+
section: ['footnotes'],
|
|
172
|
+
ol: ['footnotes-list'],
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* The prefix every id in a rendered document carries. The document and the
|
|
176
|
+
* page around it share one id space, and the page's script finds its own
|
|
177
|
+
* elements by id: a README with `<div id="jump">` would otherwise be what
|
|
178
|
+
* openJump() found and tried to show as a dialog. GitHub prefixes with the
|
|
179
|
+
* same word. Links within the document (`#install`, a footnote's `#fn1`, a
|
|
180
|
+
* heading's own anchor) are rewritten to match, so a document's table of
|
|
181
|
+
* contents keeps working; a link from outside the document to `/repo#install`
|
|
182
|
+
* finds nothing by that name, and the page script takes it to the prefixed
|
|
183
|
+
* element instead (followHash in src/pagescript.ts).
|
|
184
|
+
*/
|
|
185
|
+
exports.USER_CONTENT_PREFIX = 'user-content-';
|
|
186
|
+
function prefixId(attribs) {
|
|
187
|
+
if (attribs.id !== undefined && attribs.id !== '')
|
|
188
|
+
attribs.id = exports.USER_CONTENT_PREFIX + attribs.id;
|
|
189
|
+
}
|
|
141
190
|
function sanitizeOptions(opts) {
|
|
142
191
|
return {
|
|
143
192
|
allowedTags: ALLOWED_TAGS,
|
|
144
193
|
allowedAttributes: ALLOWED_ATTRS,
|
|
194
|
+
allowedClasses: ALLOWED_CLASSES,
|
|
145
195
|
allowedSchemes: ['http', 'https', 'mailto', 'ftp'],
|
|
146
196
|
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
|
|
147
197
|
allowProtocolRelative: false,
|
|
198
|
+
...(opts.showRefusedHtml ? { disallowedTagsMode: 'escape', nonTextTags: [] } : {}),
|
|
148
199
|
transformTags: {
|
|
149
200
|
// Rewriting here rather than in a renderer rule covers links written as
|
|
150
201
|
// markdown and links written as HTML with one implementation.
|
|
151
202
|
a: (tagName, attribs) => {
|
|
152
203
|
const href = attribs.href;
|
|
153
|
-
if (href &&
|
|
204
|
+
if (href && href.startsWith('#')) {
|
|
205
|
+
if (href.length > 1)
|
|
206
|
+
attribs.href = `#${exports.USER_CONTENT_PREFIX}${href.slice(1)}`;
|
|
207
|
+
}
|
|
208
|
+
else if (href && isRelative(href)) {
|
|
154
209
|
attribs.href = resolveRef(href, opts.blobBase);
|
|
155
210
|
}
|
|
156
211
|
else if (href && /^https?:/i.test(href)) {
|
|
@@ -165,6 +220,12 @@ function sanitizeOptions(opts) {
|
|
|
165
220
|
attribs.loading = 'lazy';
|
|
166
221
|
return { tagName, attribs };
|
|
167
222
|
},
|
|
223
|
+
// Every element, the two above included: sanitize-html applies this
|
|
224
|
+
// entry and then the element's own, so the id prefix lives here alone.
|
|
225
|
+
'*': (tagName, attribs) => {
|
|
226
|
+
prefixId(attribs);
|
|
227
|
+
return { tagName, attribs };
|
|
228
|
+
},
|
|
168
229
|
},
|
|
169
230
|
};
|
|
170
231
|
}
|
|
@@ -276,6 +337,134 @@ function buildMarkdownIt() {
|
|
|
276
337
|
block.children = out;
|
|
277
338
|
}
|
|
278
339
|
});
|
|
340
|
+
// Mentions ride the same shape as mochi_refs above: a core rule over the
|
|
341
|
+
// inline token stream, leaving text inside links alone. It runs after the
|
|
342
|
+
// refs rule, so a mention never lands inside a link that rule just made.
|
|
343
|
+
md.core.ruler.push('mochi_mentions', (state) => {
|
|
344
|
+
const mentions = state.env.opts.mentions;
|
|
345
|
+
if (!mentions)
|
|
346
|
+
return;
|
|
347
|
+
for (const block of state.tokens) {
|
|
348
|
+
if (block.type !== 'inline' || !block.children)
|
|
349
|
+
continue;
|
|
350
|
+
const out = [];
|
|
351
|
+
let inLink = 0;
|
|
352
|
+
let changed = false;
|
|
353
|
+
for (const token of block.children) {
|
|
354
|
+
if (token.type === 'link_open')
|
|
355
|
+
inLink++;
|
|
356
|
+
else if (token.type === 'link_close')
|
|
357
|
+
inLink--;
|
|
358
|
+
if (token.type !== 'text' || inLink > 0) {
|
|
359
|
+
out.push(token);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
let last = 0;
|
|
363
|
+
MENTION.lastIndex = 0;
|
|
364
|
+
for (let m = MENTION.exec(token.content); m; m = MENTION.exec(token.content)) {
|
|
365
|
+
// Dots, dashes, and underscores are legal in a username and common
|
|
366
|
+
// as the punctuation right after one: "@bob." at the end of a
|
|
367
|
+
// sentence means bob. Trailing punctuation is peeled only while the
|
|
368
|
+
// name does not resolve, so a username that really ends that way
|
|
369
|
+
// still wins.
|
|
370
|
+
let name = m[1];
|
|
371
|
+
while (name !== '' && !mentions(name)) {
|
|
372
|
+
const cut = name.search(/[._-]+$/);
|
|
373
|
+
if (cut === -1) {
|
|
374
|
+
name = '';
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
name = name.slice(0, cut);
|
|
378
|
+
}
|
|
379
|
+
if (name === '')
|
|
380
|
+
continue;
|
|
381
|
+
if (m.index > last) {
|
|
382
|
+
const before = new state.Token('text', '', 0);
|
|
383
|
+
before.content = token.content.slice(last, m.index);
|
|
384
|
+
out.push(before);
|
|
385
|
+
}
|
|
386
|
+
const open = new state.Token('link_open', 'a', 1);
|
|
387
|
+
open.attrSet('href', `/${encodeURIComponent(name)}`);
|
|
388
|
+
const label = new state.Token('text', '', 0);
|
|
389
|
+
label.content = `@${name}`;
|
|
390
|
+
const close = new state.Token('link_close', 'a', -1);
|
|
391
|
+
out.push(open, label, close);
|
|
392
|
+
last = m.index + 1 + name.length;
|
|
393
|
+
MENTION.lastIndex = last;
|
|
394
|
+
changed = true;
|
|
395
|
+
}
|
|
396
|
+
if (last === 0) {
|
|
397
|
+
out.push(token);
|
|
398
|
+
}
|
|
399
|
+
else if (last < token.content.length) {
|
|
400
|
+
const rest = new state.Token('text', '', 0);
|
|
401
|
+
rest.content = token.content.slice(last);
|
|
402
|
+
out.push(rest);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (changed)
|
|
406
|
+
block.children = out;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
// Channel names, by the mentions rule's shape and after it.
|
|
410
|
+
md.core.ruler.push('mochi_channels', (state) => {
|
|
411
|
+
const channels = state.env.opts.channels;
|
|
412
|
+
if (!channels)
|
|
413
|
+
return;
|
|
414
|
+
for (const block of state.tokens) {
|
|
415
|
+
if (block.type !== 'inline' || !block.children)
|
|
416
|
+
continue;
|
|
417
|
+
const out = [];
|
|
418
|
+
let inLink = 0;
|
|
419
|
+
let changed = false;
|
|
420
|
+
for (const token of block.children) {
|
|
421
|
+
if (token.type === 'link_open')
|
|
422
|
+
inLink++;
|
|
423
|
+
else if (token.type === 'link_close')
|
|
424
|
+
inLink--;
|
|
425
|
+
if (token.type !== 'text' || inLink > 0) {
|
|
426
|
+
out.push(token);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let last = 0;
|
|
430
|
+
CHANNEL_REF.lastIndex = 0;
|
|
431
|
+
for (let m = CHANNEL_REF.exec(token.content); m; m = CHANNEL_REF.exec(token.content)) {
|
|
432
|
+
let name = m[1];
|
|
433
|
+
let href = channels(name);
|
|
434
|
+
while (href === null && /-$/.test(name)) {
|
|
435
|
+
name = name.replace(/-+$/, '');
|
|
436
|
+
href = name === '' ? null : channels(name);
|
|
437
|
+
}
|
|
438
|
+
if (href === null)
|
|
439
|
+
continue;
|
|
440
|
+
if (m.index > last) {
|
|
441
|
+
const before = new state.Token('text', '', 0);
|
|
442
|
+
before.content = token.content.slice(last, m.index);
|
|
443
|
+
out.push(before);
|
|
444
|
+
}
|
|
445
|
+
const open = new state.Token('link_open', 'a', 1);
|
|
446
|
+
open.attrSet('href', href);
|
|
447
|
+
const label = new state.Token('text', '', 0);
|
|
448
|
+
label.content = `#${name}`;
|
|
449
|
+
const close = new state.Token('link_close', 'a', -1);
|
|
450
|
+
out.push(open, label, close);
|
|
451
|
+
last = m.index + 1 + name.length;
|
|
452
|
+
CHANNEL_REF.lastIndex = last;
|
|
453
|
+
changed = true;
|
|
454
|
+
}
|
|
455
|
+
if (last === 0) {
|
|
456
|
+
out.push(token);
|
|
457
|
+
}
|
|
458
|
+
else if (last < token.content.length) {
|
|
459
|
+
const rest = new state.Token('text', '', 0);
|
|
460
|
+
rest.content = token.content.slice(last);
|
|
461
|
+
out.push(rest);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (changed)
|
|
465
|
+
block.children = out;
|
|
466
|
+
}
|
|
467
|
+
});
|
|
279
468
|
md.renderer.rules.fence = (tokens, idx, _options, env) => {
|
|
280
469
|
const token = tokens[idx];
|
|
281
470
|
const info = token.info.trim().split(/\s+/)[0].toLowerCase();
|
package/dist/mdedit.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.previewUrl = previewUrl;
|
|
4
|
+
exports.markdownEditor = markdownEditor;
|
|
5
|
+
const html_1 = require("./html");
|
|
6
|
+
const icons_1 = require("./icons");
|
|
7
|
+
const views_1 = require("./views");
|
|
8
|
+
function previewUrl(ctx) {
|
|
9
|
+
return `${(0, views_1.repoUrl)(ctx)}/preview`;
|
|
10
|
+
}
|
|
11
|
+
function markdownEditor(o) {
|
|
12
|
+
const btn = (act, glyph, title) => (0, html_1.html) `<button type="button" class="md-btn" data-md-act="${act}" title="${title}" aria-label="${title}" tabindex="-1">${(0, icons_1.icon)(glyph)}</button>`;
|
|
13
|
+
const groups = [
|
|
14
|
+
(0, html_1.html) `<span class="md-group">${btn('heading', 'heading', 'Heading')}${btn('bold', 'bold', 'Bold (Ctrl+B)')}${btn('italic', 'italic', 'Italic (Ctrl+I)')}${btn('strike', 'strikethrough', 'Strikethrough')}</span>`,
|
|
15
|
+
(0, html_1.html) `<span class="md-group">${btn('quote', 'quote', 'Quote')}${btn('code', 'code', 'Code (Ctrl+E)')}${btn('link', 'link', 'Link (Ctrl+K)')}</span>`,
|
|
16
|
+
(0, html_1.html) `<span class="md-group">${btn('ul', 'list-unordered', 'Bulleted list')}${btn('ol', 'list-ordered', 'Numbered list')}${btn('task', 'tasklist', 'Task list')}</span>`,
|
|
17
|
+
];
|
|
18
|
+
return (0, html_1.html) `<div class="md-editor" data-md-editor data-md-preview="${o.preview}"${o.ref ? (0, html_1.html) ` data-md-ref="${o.ref}"` : ''}${o.dir ? (0, html_1.html) ` data-md-dir="${o.dir}"` : ''}>
|
|
19
|
+
<div class="md-head">
|
|
20
|
+
<div class="md-tabs" role="tablist">
|
|
21
|
+
<button type="button" class="md-tab current" data-md-pane="write" role="tab" aria-selected="true">Write</button>
|
|
22
|
+
<button type="button" class="md-tab" data-md-pane="preview" role="tab" aria-selected="false">Preview</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="md-toolbar" role="toolbar" aria-label="Formatting">${(0, html_1.joinHtml)(groups)}</div>
|
|
25
|
+
</div>
|
|
26
|
+
<textarea class="${o.codeEditor ? 'code-editor ' : ''}md-input" name="${o.name}"${o.id ? (0, html_1.html) ` id="${o.id}"` : ''} rows="${String(o.rows)}"${o.placeholder ? (0, html_1.html) ` placeholder="${o.placeholder}"` : ''}${o.codeEditor ? (0, html_1.html) ` spellcheck="false"` : ''}>${o.value ?? ''}</textarea>
|
|
27
|
+
<div class="md-render markdown-body" hidden></div>
|
|
28
|
+
</div>`;
|
|
29
|
+
}
|
package/dist/migrate.js
CHANGED
|
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.migrateLayout = migrateLayout;
|
|
37
37
|
exports.migratePermissions = migratePermissions;
|
|
38
|
+
exports.migratePushPolicy = migratePushPolicy;
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const path = __importStar(require("path"));
|
|
40
42
|
const layout_1 = require("./layout");
|
|
@@ -332,3 +334,49 @@ function migratePermissions(root) {
|
|
|
332
334
|
(0, vault_1.finishPermsMigration)(root, siteAdmins);
|
|
333
335
|
return notes;
|
|
334
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Unset `receive.denyNonFastForwards` on every repository that still carries
|
|
339
|
+
* it.
|
|
340
|
+
*
|
|
341
|
+
* Repositories created before force pushes were allowed were configured to
|
|
342
|
+
* refuse any push that moved a branch to a commit that was not a descendant of
|
|
343
|
+
* where it already was. Nothing overrides that per push, so a vault upgraded
|
|
344
|
+
* from that era would keep refusing force pushes on its existing repositories
|
|
345
|
+
* while allowing them on every new one, which is the sort of split nobody could
|
|
346
|
+
* diagnose from the outside.
|
|
347
|
+
*
|
|
348
|
+
* The setting is read from the repository's own config file before git is run,
|
|
349
|
+
* so a vault whose repositories are all current pays a small read each and
|
|
350
|
+
* spawns nothing. Deletes are deliberately left refused: see createRepo.
|
|
351
|
+
*/
|
|
352
|
+
function migratePushPolicy(root) {
|
|
353
|
+
const changed = [];
|
|
354
|
+
for (const { name: collection } of (0, scan_1.listCollections)(root)) {
|
|
355
|
+
for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
|
|
356
|
+
const dir = path.join((0, layout_1.collectionsDir)(root), collection, layout_1.REPOS_DIR, dirName);
|
|
357
|
+
const configFile = path.join(dir, 'config');
|
|
358
|
+
let text;
|
|
359
|
+
try {
|
|
360
|
+
text = fs.readFileSync(configFile, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (!/denyNonFastForwards/i.test(text))
|
|
366
|
+
continue;
|
|
367
|
+
try {
|
|
368
|
+
(0, child_process_1.execFileSync)('git', ['-C', dir, 'config', '--unset-all', 'receive.denyNonFastForwards'], {
|
|
369
|
+
stdio: 'ignore',
|
|
370
|
+
});
|
|
371
|
+
changed.push(`${collection}/${(0, scan_1.displayName)(dirName)}`);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// A repository whose config could not be changed keeps refusing force
|
|
375
|
+
// pushes, which is the behaviour it has had all along. It is named so
|
|
376
|
+
// that the operator can unset it by hand.
|
|
377
|
+
changed.push(`${collection}/${(0, scan_1.displayName)(dirName)} (failed)`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return changed;
|
|
382
|
+
}
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The spellings that name the product rather than describe the mechanism.
|
|
3
|
+
//
|
|
4
|
+
// Everything here defaults to the mochi spellings, so mochiforge itself is
|
|
5
|
+
// unchanged by this file existing. A sibling application built on these
|
|
6
|
+
// modules (dango is the first) calls setNaming() once, before anything else
|
|
7
|
+
// runs, and the token prefix, session cookie, state file, environment
|
|
8
|
+
// variables, and CLI wording all take its name instead. The values are read
|
|
9
|
+
// at call time, never captured at module load, which is what makes the one
|
|
10
|
+
// early setNaming() call sufficient.
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.naming = void 0;
|
|
13
|
+
exports.setNaming = setNaming;
|
|
14
|
+
exports.naming = {
|
|
15
|
+
product: 'mochi',
|
|
16
|
+
tokenPrefix: 'mochi_',
|
|
17
|
+
cookieName: 'mochi_session',
|
|
18
|
+
stateFile: 'vault.json',
|
|
19
|
+
rootNoun: 'vault',
|
|
20
|
+
envPrefix: 'MOCHI',
|
|
21
|
+
configDirName: 'mochi',
|
|
22
|
+
};
|
|
23
|
+
function setNaming(overrides) {
|
|
24
|
+
Object.assign(exports.naming, overrides);
|
|
25
|
+
}
|