@magland/mochi 0.3.1 → 0.4.1
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 +166 -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 +193 -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
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A self-hosted git forge, GitHub-shaped: repository browsing, in-browser editing,
|
|
|
4
4
|
|
|
5
5
|
Mochi Forge is usually shortened to *mochi*, which is how the command, the npm package, and the vault's own files are spelled.
|
|
6
6
|
|
|
7
|
-
Reading is anonymous, unless a repository is made private, in which case it is visible only to its collaborators, its collection's owners, and site admins. Every write is authorized by a token, and users are created by an administrator rather than registering themselves. Permissions are GitHub-shaped: a user owns the collection named after them, repositories take collaborators with read, write, or admin roles, and one site-admin bit runs the vault.
|
|
7
|
+
Reading is anonymous, unless a repository is made private, in which case it is visible only to its collaborators, its collection's owners, and site admins. Every write is authorized by a token, and users are created by an administrator rather than registering themselves. Permissions are GitHub-shaped: a user owns the collection named after them, repositories take collaborators with read, write, or admin roles, and one site-admin bit runs the vault. Signing in on the web takes the token once; after that a passkey signs in with a touch, a short code carries a session to another device, and `mochi web` opens a signed-in browser from the terminal.
|
|
8
8
|
|
|
9
9
|
## Try it locally
|
|
10
10
|
|
|
@@ -39,8 +39,9 @@ The same command deploys updates. See [Deploying a vault](docs/deploying.md) for
|
|
|
39
39
|
- **Editing in the browser:** files, uploads, branches, tags, repositories, collections, forks, users. Controls a token cannot use are not shown.
|
|
40
40
|
- **Issues and pull requests,** stored as markdown in the vault. Merge or squash, refused on conflicts.
|
|
41
41
|
- **Releases** tied to a tag, with Atom feeds.
|
|
42
|
+
- **Forking from GitHub:** `mochi fork` imports a repository and records its upstream, `mochi sync` fast-forwards from it, and `mochi pr export` sends a pull request made here on to GitHub as one of yours. All three run on your machine, through your own git and `gh` credentials; the vault holds no GitHub token.
|
|
42
43
|
- **Workflows:** GitHub Actions workflows, planned by the server and run by a Docker runner you start elsewhere, including one deployed to Fly.io with a command, which stops when idle and is woken by the vault when a job is queued. A job marked `runs-on: manual` instead waits for a command you paste on a machine of your choosing, which shows the steps and asks before executing them.
|
|
43
|
-
- **Sites:**
|
|
44
|
+
- **Sites:** an opt-in static site per repository, sandboxed by default, optionally on its own hostname or a custom domain.
|
|
44
45
|
- **Git:** anonymous clone over smart HTTP for public repositories and authenticated clone for private ones, token-authenticated push including push-to-create, and LFS to S3 or to the vault.
|
|
45
46
|
- **CLI and JSON API** covering everything the web UI does, plus a generic `mochi api`. Built for scripts: `--json` everywhere, distinct exit codes, no prompts.
|
|
46
47
|
|
|
@@ -70,17 +71,17 @@ No database, no state outside the directory. Backup is `cp -a`, migration is `rs
|
|
|
70
71
|
- [The command line](docs/cli.md) - the `mochi` command and its subcommands
|
|
71
72
|
- [The vault](docs/vault.md) - the on-disk layout, tokens, and sessions
|
|
72
73
|
- [Deploying a vault](docs/deploying.md) | [Backing up a vault](docs/backup.md)
|
|
73
|
-
- [Workflows](docs/workflows.md) | [Sites](docs/sites.md) | [Git LFS](docs/lfs.md) | [Themes](docs/themes.md) | [Issues and pull requests](docs/issues-and-pull-requests.md)
|
|
74
|
+
- [Workflows](docs/workflows.md) | [Sites](docs/sites.md) | [Git LFS](docs/lfs.md) | [Themes](docs/themes.md) | [Issues and pull requests](docs/issues-and-pull-requests.md) | [Encrypted files](docs/encrypted-files.md)
|
|
74
75
|
- [The JSON API](docs/api.md) - every route, body, and response
|
|
75
76
|
- [Mochi Forge for an agent](docs/agents.md) - short enough to paste into a context window
|
|
76
77
|
|
|
77
78
|
## Using a vault from Claude Code
|
|
78
79
|
|
|
79
|
-
[
|
|
80
|
+
[mochiforge-skill](https://github.com/magland/mochiforge-skill) teaches an agent this CLI the way it already knows `gh`.
|
|
80
81
|
|
|
81
82
|
```
|
|
82
|
-
/plugin marketplace add magland/
|
|
83
|
-
/plugin install mochi@
|
|
83
|
+
/plugin marketplace add magland/mochiforge-skill
|
|
84
|
+
/plugin install mochi@mochiforge-skill
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
The agent needs `mochi` on its PATH and either `MOCHI_HOST`/`MOCHI_TOKEN` or a completed `mochi login`.
|
package/dist/agefile.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Age-encrypted files, as the web interface understands them.
|
|
3
|
+
//
|
|
4
|
+
// A file named `*.age` is treated as an age encryption (age-encryption.org)
|
|
5
|
+
// ciphertext: the blob page offers to decrypt it in the browser, and the web
|
|
6
|
+
// editor encrypts before committing, so the plaintext and the passphrase
|
|
7
|
+
// never reach the server. The extension is the whole contract, the way `.md`
|
|
8
|
+
// is: the vault stores and serves bytes it cannot read, and the same file
|
|
9
|
+
// decrypts with the standard `age` CLI anywhere else.
|
|
10
|
+
//
|
|
11
|
+
// The two spellings of the format are both honoured when reading: the binary
|
|
12
|
+
// framing, which begins with the version line, and the ASCII armor the spec
|
|
13
|
+
// defines (and the browser editor always writes, so its commits diff as
|
|
14
|
+
// text). What this module decides is only naming and framing; the cryptography
|
|
15
|
+
// itself happens in the reader's browser, in the vendored typage bundle (see
|
|
16
|
+
// src/vendor-age.ts), or in whatever age client the user prefers.
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.isAgeFile = isAgeFile;
|
|
19
|
+
exports.ageInnerName = ageInnerName;
|
|
20
|
+
exports.looksLikeAge = looksLikeAge;
|
|
21
|
+
/** Whether this path names an age ciphertext, by its extension. */
|
|
22
|
+
function isAgeFile(path) {
|
|
23
|
+
return /\.age$/i.test(path);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The name inside the encryption: `secrets.md.age` is a markdown file, and
|
|
27
|
+
* the viewer renders the decrypted text accordingly. A bare `x.age` has no
|
|
28
|
+
* inner extension and is shown as plain text.
|
|
29
|
+
*/
|
|
30
|
+
function ageInnerName(path) {
|
|
31
|
+
return path.replace(/\.age$/i, '');
|
|
32
|
+
}
|
|
33
|
+
const BINARY_HEADER = Buffer.from('age-encryption.org/v1\n');
|
|
34
|
+
const ARMOR_HEADER = Buffer.from('-----BEGIN AGE ENCRYPTED FILE-----');
|
|
35
|
+
/**
|
|
36
|
+
* Whether these bytes begin the way an age ciphertext does, in either
|
|
37
|
+
* framing. This is what stands between a failed script and a plaintext
|
|
38
|
+
* commit: the web forms refuse to write a `*.age` path whose content is not
|
|
39
|
+
* age-shaped, because the likeliest way to produce one is a browser that
|
|
40
|
+
* never ran the encryption. It is a framing check, not a validation; a
|
|
41
|
+
* truncated or corrupted ciphertext still passes and fails at decryption,
|
|
42
|
+
* where it can be reported honestly.
|
|
43
|
+
*/
|
|
44
|
+
function looksLikeAge(buf) {
|
|
45
|
+
return (buf.subarray(0, BINARY_HEADER.length).equals(BINARY_HEADER) ||
|
|
46
|
+
buf.subarray(0, ARMOR_HEADER.length).equals(ARMOR_HEADER));
|
|
47
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ageScript = ageScript;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const vendor_age_1 = require("./vendor-age");
|
|
6
|
+
// The script for pages that touch age-encrypted files: the blob view of a
|
|
7
|
+
// `*.age` file, the editor for one, and the new-file form (which may be given
|
|
8
|
+
// a `*.age` name). Served as /assets/age.js and loaded only by those pages,
|
|
9
|
+
// so the 300 KB of vendored cryptography is never fetched by a page that
|
|
10
|
+
// cannot need it; ordinary pages carry /assets/page.js alone.
|
|
11
|
+
//
|
|
12
|
+
// Everything here runs on the reader's side of the trust line. The server
|
|
13
|
+
// hands out ciphertext and receives ciphertext; the passphrase lives in a
|
|
14
|
+
// closure for exactly as long as the page needs it, is never written to
|
|
15
|
+
// storage of any kind, and the plaintext exists only in the DOM of this open
|
|
16
|
+
// page. That is the entire point of the feature, so nothing below may relax
|
|
17
|
+
// it. See docs/encrypted-files.md for the contract as the user sees it.
|
|
18
|
+
//
|
|
19
|
+
// Like page.js, everything page-specific arrives through data attributes
|
|
20
|
+
// (data-age-view, data-age-edit, data-age-new and their companions), so this
|
|
21
|
+
// file is one cacheable body for every vault. Unlike page.js it may assume a
|
|
22
|
+
// modern browser: WebCrypto is required by the cryptography itself, so there
|
|
23
|
+
// is nobody older to degrade for. The vendored bundle (src/vendor-age.ts)
|
|
24
|
+
// provides window.MochiAge (typage) and window.MochiMarkdownIt.
|
|
25
|
+
const GLUE_JS = `
|
|
26
|
+
(function () {
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
var ARMOR_HEAD = '-----BEGIN AGE ENCRYPTED FILE-----';
|
|
30
|
+
|
|
31
|
+
function fetchCiphertext(url) {
|
|
32
|
+
return fetch(url).then(function (resp) {
|
|
33
|
+
if (!resp.ok) throw new Error('Could not fetch the file (HTTP ' + resp.status + ').');
|
|
34
|
+
return resp.arrayBuffer();
|
|
35
|
+
}).then(function (buf) { return new Uint8Array(buf); });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Both framings are read: the armor the browser editor writes, and the
|
|
39
|
+
// binary framing an age CLI writes by default.
|
|
40
|
+
function decodeFramed(bytes) {
|
|
41
|
+
var head = '';
|
|
42
|
+
for (var i = 0; i < Math.min(bytes.length, ARMOR_HEAD.length); i++) head += String.fromCharCode(bytes[i]);
|
|
43
|
+
if (head === ARMOR_HEAD) return window.MochiAge.armor.decode(new TextDecoder().decode(bytes));
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decryptText(bytes, pass) {
|
|
48
|
+
var d = new window.MochiAge.Decrypter();
|
|
49
|
+
d.addPassphrase(pass);
|
|
50
|
+
return d.decrypt(decodeFramed(bytes), 'text');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Armored output on purpose: it commits as text, so the vault's diffs,
|
|
54
|
+
// blame, and text-shaped write path all keep working over the ciphertext.
|
|
55
|
+
function encryptArmored(text, pass) {
|
|
56
|
+
var e = new window.MochiAge.Encrypter();
|
|
57
|
+
e.setPassphrase(pass);
|
|
58
|
+
return e.encrypt(text).then(function (bytes) { return window.MochiAge.armor.encode(bytes) + '\\n'; });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Passphrase failures all look alike from the outside, and honesty about
|
|
62
|
+
// which it was is not available: age reports only that no recipient matched.
|
|
63
|
+
function explain(err) {
|
|
64
|
+
var msg = err && err.message ? String(err.message) : String(err);
|
|
65
|
+
if (msg.indexOf('no identity matched') !== -1) {
|
|
66
|
+
return 'That passphrase does not open this file. (A corrupted file reports the same way.)';
|
|
67
|
+
}
|
|
68
|
+
if (msg.indexOf('invalid header') !== -1 || msg.indexOf('parsing header') !== -1) {
|
|
69
|
+
return 'This is not a readable age file: ' + msg;
|
|
70
|
+
}
|
|
71
|
+
return msg;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setError(box, msg) {
|
|
75
|
+
var el = box.querySelector('.age-error');
|
|
76
|
+
if (!el) return;
|
|
77
|
+
if (msg) { el.textContent = msg; el.hidden = false; } else { el.hidden = true; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A working button says so. The scrypt derivation takes long enough to feel,
|
|
81
|
+
// so while it runs the button shows the busy label the server put on it
|
|
82
|
+
// ('Decrypting...', 'Encrypting...') rather than reading as a dead click.
|
|
83
|
+
function setBtnBusy(btn, busy) {
|
|
84
|
+
if (!btn) return;
|
|
85
|
+
btn.disabled = busy;
|
|
86
|
+
var label = btn.getAttribute('data-busy-label');
|
|
87
|
+
if (!label) return;
|
|
88
|
+
if (busy) {
|
|
89
|
+
if (!btn.getAttribute('data-idle-label')) btn.setAttribute('data-idle-label', btn.textContent);
|
|
90
|
+
btn.textContent = label;
|
|
91
|
+
} else if (btn.getAttribute('data-idle-label')) {
|
|
92
|
+
btn.textContent = btn.getAttribute('data-idle-label');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setBusy(box, busy) {
|
|
97
|
+
setBtnBusy(box.querySelector('.age-unlock button[type=submit]'), busy);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A failed passphrase leaves the input focused and selected, so the retry
|
|
101
|
+
// is a retype rather than a clear-then-retype.
|
|
102
|
+
function failedPass(input) { input.focus(); input.select(); }
|
|
103
|
+
|
|
104
|
+
// Every passphrase input arrives wrapped with a show/hide toggle (the
|
|
105
|
+
// .age-eye button the server renders beside it). Only the input's type
|
|
106
|
+
// flips; the value still never carries a name, so it still cannot post.
|
|
107
|
+
function wireEyes() {
|
|
108
|
+
var wraps = document.querySelectorAll('.age-pass-wrap');
|
|
109
|
+
for (var i = 0; i < wraps.length; i++) (function (wrap) {
|
|
110
|
+
var input = wrap.querySelector('input');
|
|
111
|
+
var btn = wrap.querySelector('.age-eye');
|
|
112
|
+
if (!input || !btn) return;
|
|
113
|
+
btn.addEventListener('click', function () {
|
|
114
|
+
var show = input.type === 'password';
|
|
115
|
+
input.type = show ? 'text' : 'password';
|
|
116
|
+
wrap.classList.toggle('showing', show);
|
|
117
|
+
btn.setAttribute('aria-pressed', show ? 'true' : 'false');
|
|
118
|
+
btn.setAttribute('aria-label', show ? 'Hide the passphrase' : 'Show the passphrase');
|
|
119
|
+
input.focus();
|
|
120
|
+
});
|
|
121
|
+
})(wraps[i]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The plaintext to copy comes from the closure, not from the DOM, so it is
|
|
125
|
+
// exact bytes even where the output was rendered as a markdown document.
|
|
126
|
+
function copyPlain(btn, text) {
|
|
127
|
+
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
|
|
128
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
129
|
+
btn.classList.add('copied');
|
|
130
|
+
setTimeout(function () { btn.classList.remove('copied'); }, 1400);
|
|
131
|
+
}, function () {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- the blob page ----
|
|
135
|
+
|
|
136
|
+
// The decrypted file is rendered the way its inner name asks: markdown as a
|
|
137
|
+
// document, anything else as preformatted text. Rendering happens here
|
|
138
|
+
// rather than on the server for the same reason decryption does.
|
|
139
|
+
function renderPlain(out, inner, text) {
|
|
140
|
+
out.textContent = '';
|
|
141
|
+
if (inner === 'markdown') {
|
|
142
|
+
var md = window.MochiMarkdownIt({ html: false, linkify: true });
|
|
143
|
+
var doc = document.createElement('div');
|
|
144
|
+
doc.className = 'rendered markdown-body';
|
|
145
|
+
doc.innerHTML = md.render(text);
|
|
146
|
+
out.appendChild(doc);
|
|
147
|
+
} else {
|
|
148
|
+
var pre = document.createElement('pre');
|
|
149
|
+
pre.className = 'age-plain';
|
|
150
|
+
pre.textContent = text;
|
|
151
|
+
out.appendChild(pre);
|
|
152
|
+
}
|
|
153
|
+
out.hidden = false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// On unlock the explanatory card gives way to a slim bar over the output:
|
|
157
|
+
// a note that the decryption stayed in the page, a copy of the exact
|
|
158
|
+
// plaintext, and the lock that puts the card back. Locking drops the
|
|
159
|
+
// plaintext from the closure as well as from the DOM.
|
|
160
|
+
function wireView(box) {
|
|
161
|
+
var form = box.querySelector('form.age-unlock');
|
|
162
|
+
var pass = form ? form.querySelector('input') : null;
|
|
163
|
+
var out = box.querySelector('.age-output');
|
|
164
|
+
var card = box.querySelector('.age-card');
|
|
165
|
+
var bar = box.querySelector('[data-age-bar]');
|
|
166
|
+
var lock = box.querySelector('[data-age-lock]');
|
|
167
|
+
var copy = box.querySelector('[data-age-copy]');
|
|
168
|
+
if (!form || !pass || !out || !card) return;
|
|
169
|
+
var plain = null;
|
|
170
|
+
form.addEventListener('submit', function (ev) {
|
|
171
|
+
ev.preventDefault();
|
|
172
|
+
setError(card, null);
|
|
173
|
+
setBusy(card, true);
|
|
174
|
+
fetchCiphertext(box.getAttribute('data-age-raw'))
|
|
175
|
+
.then(function (bytes) { return decryptText(bytes, pass.value); })
|
|
176
|
+
.then(function (text) {
|
|
177
|
+
plain = text;
|
|
178
|
+
pass.value = '';
|
|
179
|
+
renderPlain(out, box.getAttribute('data-age-inner'), text);
|
|
180
|
+
card.hidden = true;
|
|
181
|
+
if (bar) bar.hidden = false;
|
|
182
|
+
})
|
|
183
|
+
.catch(function (err) { setError(card, explain(err)); failedPass(pass); })
|
|
184
|
+
.then(function () { setBusy(card, false); });
|
|
185
|
+
});
|
|
186
|
+
if (lock) lock.addEventListener('click', function () {
|
|
187
|
+
plain = null;
|
|
188
|
+
out.textContent = '';
|
|
189
|
+
out.hidden = true;
|
|
190
|
+
if (bar) bar.hidden = true;
|
|
191
|
+
card.hidden = false;
|
|
192
|
+
pass.focus();
|
|
193
|
+
});
|
|
194
|
+
if (copy) copy.addEventListener('click', function () {
|
|
195
|
+
if (plain !== null) copyPlain(copy, plain);
|
|
196
|
+
});
|
|
197
|
+
pass.focus();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- the editor ----
|
|
201
|
+
|
|
202
|
+
// The server cannot fill the textarea, so the page starts locked: the same
|
|
203
|
+
// unlock form as the blob view, and on success the plaintext lands in the
|
|
204
|
+
// editor and the passphrase stays in this closure for the re-encryption at
|
|
205
|
+
// commit time. The textarea carries no name; what the form posts is the
|
|
206
|
+
// hidden content field, written at the last moment with fresh ciphertext.
|
|
207
|
+
// An optional pair of new-passphrase inputs (also nameless) re-keys the
|
|
208
|
+
// file: filled and matching, the commit encrypts with the new passphrase.
|
|
209
|
+
function wireEdit(form) {
|
|
210
|
+
var passForm = document.querySelector('form.age-unlock[data-age-for-edit]');
|
|
211
|
+
var passInput = passForm ? passForm.querySelector('input') : null;
|
|
212
|
+
var card = passForm ? passForm.closest('.age-card') : null;
|
|
213
|
+
var editor = form.querySelector('textarea.code-editor');
|
|
214
|
+
var content = form.querySelector('input[name=content]');
|
|
215
|
+
var commitBtn = form.querySelector('button[type=submit]');
|
|
216
|
+
var path = form.querySelector('input[name=path]');
|
|
217
|
+
var renameWarn = form.querySelector('[data-age-rename-warn]');
|
|
218
|
+
var newPass = form.querySelectorAll('.age-newpass input');
|
|
219
|
+
if (!passForm || !passInput || !card || !editor || !content || !commitBtn) return;
|
|
220
|
+
var passphrase = null;
|
|
221
|
+
var readyToPost = false;
|
|
222
|
+
passForm.addEventListener('submit', function (ev) {
|
|
223
|
+
ev.preventDefault();
|
|
224
|
+
setError(card, null);
|
|
225
|
+
setBusy(card, true);
|
|
226
|
+
var pass = passInput.value;
|
|
227
|
+
fetchCiphertext(form.getAttribute('data-age-raw'))
|
|
228
|
+
.then(function (bytes) { return decryptText(bytes, pass); })
|
|
229
|
+
.then(function (text) {
|
|
230
|
+
passphrase = pass;
|
|
231
|
+
passInput.value = '';
|
|
232
|
+
editor.value = text;
|
|
233
|
+
editor.disabled = false;
|
|
234
|
+
commitBtn.disabled = false;
|
|
235
|
+
card.hidden = true;
|
|
236
|
+
form.hidden = false;
|
|
237
|
+
editor.focus();
|
|
238
|
+
})
|
|
239
|
+
.catch(function (err) { setError(card, explain(err)); failedPass(passInput); })
|
|
240
|
+
.then(function () { setBusy(card, false); });
|
|
241
|
+
});
|
|
242
|
+
// Renaming away from .age is legal but almost never meant: the commit
|
|
243
|
+
// would still be ciphertext, under a name the vault reads as plain text.
|
|
244
|
+
// The warning appears as the name changes, not as a surprise afterwards.
|
|
245
|
+
if (path && renameWarn) path.addEventListener('input', function () {
|
|
246
|
+
renameWarn.hidden = /\\.age$/i.test(path.value.trim());
|
|
247
|
+
});
|
|
248
|
+
form.addEventListener('submit', function (ev) {
|
|
249
|
+
if (readyToPost) return;
|
|
250
|
+
ev.preventDefault();
|
|
251
|
+
if (passphrase === null) return;
|
|
252
|
+
var usePass = passphrase;
|
|
253
|
+
if (newPass.length === 2 && (newPass[0].value !== '' || newPass[1].value !== '')) {
|
|
254
|
+
if (newPass[0].value !== newPass[1].value) {
|
|
255
|
+
setError(form, 'The new passphrases do not match.');
|
|
256
|
+
failedPass(newPass[1]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
usePass = newPass[0].value;
|
|
260
|
+
}
|
|
261
|
+
setError(form, null);
|
|
262
|
+
setBtnBusy(commitBtn, true);
|
|
263
|
+
encryptArmored(editor.value, usePass)
|
|
264
|
+
.then(function (armored) {
|
|
265
|
+
content.value = armored;
|
|
266
|
+
readyToPost = true;
|
|
267
|
+
form.submit();
|
|
268
|
+
})
|
|
269
|
+
.catch(function (err) {
|
|
270
|
+
setBtnBusy(commitBtn, false);
|
|
271
|
+
setError(form, explain(err));
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
passInput.focus();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- the new-file form ----
|
|
278
|
+
|
|
279
|
+
// The form is the ordinary one; a file name ending in .age reveals the
|
|
280
|
+
// passphrase pair (and retires the hint that said so) and switches the
|
|
281
|
+
// commit to encrypt-then-post. The passphrase inputs carry no name, so
|
|
282
|
+
// they can never be posted, and the server refuses a .age path whose
|
|
283
|
+
// content is not age-shaped, so a page where this script failed cannot
|
|
284
|
+
// commit plaintext under the name.
|
|
285
|
+
function wireNew(form) {
|
|
286
|
+
var filename = form.querySelector('input[name=filename]');
|
|
287
|
+
var fields = form.querySelector('.age-pass-fields');
|
|
288
|
+
var hint = form.querySelector('[data-age-hint]');
|
|
289
|
+
var editor = form.querySelector('textarea[name=content]');
|
|
290
|
+
var commitBtn = form.querySelector('button[type=submit]');
|
|
291
|
+
if (!filename || !fields || !editor) return;
|
|
292
|
+
var inputs = fields.querySelectorAll('.age-pass-wrap input');
|
|
293
|
+
if (inputs.length !== 2) return;
|
|
294
|
+
var readyToPost = false;
|
|
295
|
+
function wantsAge() { return /\\.age$/i.test(filename.value.trim()); }
|
|
296
|
+
function toggle() {
|
|
297
|
+
fields.hidden = !wantsAge();
|
|
298
|
+
if (hint) hint.hidden = wantsAge();
|
|
299
|
+
}
|
|
300
|
+
filename.addEventListener('input', toggle);
|
|
301
|
+
toggle();
|
|
302
|
+
form.addEventListener('submit', function (ev) {
|
|
303
|
+
if (readyToPost || !wantsAge()) return;
|
|
304
|
+
ev.preventDefault();
|
|
305
|
+
setError(fields, null);
|
|
306
|
+
if (inputs[0].value === '') { setError(fields, 'Choose a passphrase for this file.'); failedPass(inputs[0]); return; }
|
|
307
|
+
if (inputs[0].value !== inputs[1].value) { setError(fields, 'The passphrases do not match.'); failedPass(inputs[1]); return; }
|
|
308
|
+
setBtnBusy(commitBtn, true);
|
|
309
|
+
encryptArmored(editor.value, inputs[0].value)
|
|
310
|
+
.then(function (armored) {
|
|
311
|
+
inputs[0].value = '';
|
|
312
|
+
inputs[1].value = '';
|
|
313
|
+
editor.removeAttribute('name');
|
|
314
|
+
var hidden = document.createElement('input');
|
|
315
|
+
hidden.type = 'hidden';
|
|
316
|
+
hidden.name = 'content';
|
|
317
|
+
hidden.value = armored;
|
|
318
|
+
form.appendChild(hidden);
|
|
319
|
+
readyToPost = true;
|
|
320
|
+
form.submit();
|
|
321
|
+
})
|
|
322
|
+
.catch(function (err) { setBtnBusy(commitBtn, false); setError(fields, explain(err)); });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
wireEyes();
|
|
327
|
+
var view = document.querySelector('[data-age-view]');
|
|
328
|
+
if (view) wireView(view);
|
|
329
|
+
var edit = document.querySelector('form[data-age-edit]');
|
|
330
|
+
if (edit) wireEdit(edit);
|
|
331
|
+
var fresh = document.querySelector('form[data-age-new]');
|
|
332
|
+
if (fresh) wireNew(fresh);
|
|
333
|
+
})();
|
|
334
|
+
`;
|
|
335
|
+
let made = null;
|
|
336
|
+
/** The served asset: the vendored bundle and the glue above, one body, one tag. */
|
|
337
|
+
function ageScript() {
|
|
338
|
+
if (made)
|
|
339
|
+
return made;
|
|
340
|
+
const body = vendor_age_1.VENDOR_AGE_JS + '\n' + GLUE_JS;
|
|
341
|
+
made = { body, tag: (0, crypto_1.createHash)('sha256').update(body).digest('hex').slice(0, 12) };
|
|
342
|
+
return made;
|
|
343
|
+
}
|
package/dist/api/admin.js
CHANGED
|
@@ -38,9 +38,9 @@ const fs = __importStar(require("fs"));
|
|
|
38
38
|
const config_1 = require("../config");
|
|
39
39
|
const layout_1 = require("../layout");
|
|
40
40
|
const ops_1 = require("../ops");
|
|
41
|
-
const redirects_1 = require("../redirects");
|
|
42
41
|
const scan_1 = require("../scan");
|
|
43
42
|
const siteshost_1 = require("../siteshost");
|
|
43
|
+
const sitesettings_1 = require("../sitesettings");
|
|
44
44
|
const themes_1 = require("../themes");
|
|
45
45
|
const perms_1 = require("../perms");
|
|
46
46
|
const vault_1 = require("../vault");
|
|
@@ -119,58 +119,90 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
|
|
|
119
119
|
(0, auth_1.sendOpError)(res, e, 'could not rename the collection');
|
|
120
120
|
}
|
|
121
121
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
/**
|
|
123
|
+
* A collection's site alias: the label standing in for its name in each of
|
|
124
|
+
* its repositories' derived site hostnames. Ownership of the collection, as
|
|
125
|
+
* the rename above takes, since it decides those hostnames and nothing else.
|
|
126
|
+
*
|
|
127
|
+
* `siteAlias` is the only field, `""` clearing it and falling back to the
|
|
128
|
+
* collection's own name where that is usable as a hostname label and its name
|
|
129
|
+
* rewritten as one otherwise. An alias another collection is already reached
|
|
130
|
+
* by is refused with 409, naming it, the same answer a claimed repository
|
|
131
|
+
* label gets.
|
|
132
|
+
*/
|
|
133
|
+
app.patch('/api/collections/:name', (req, res) => {
|
|
126
134
|
const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
|
|
127
135
|
if (!auth)
|
|
128
136
|
return;
|
|
129
137
|
const name = req.params.name;
|
|
138
|
+
if (!(0, scan_1.isValidName)(name) || !isCollection(name)) {
|
|
139
|
+
(0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
130
142
|
if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
|
|
131
143
|
(0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
|
|
132
144
|
return;
|
|
133
145
|
}
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
const alias = (0, auth_1.stringField)((0, auth_1.bodyOf)(req), 'siteAlias');
|
|
147
|
+
if (alias === null) {
|
|
148
|
+
(0, auth_1.apiError)(res, 400, 'nothing to change; provide "siteAlias"');
|
|
136
149
|
return;
|
|
137
150
|
}
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
if (alias !== '' && !(0, sitesettings_1.isUsableCollectionAlias)(alias)) {
|
|
152
|
+
(0, auth_1.apiError)(res, 400, '"siteAlias" is lowercase letters, digits, and single interior hyphens, with no doubled hyphen, at most 63 characters');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (alias !== '') {
|
|
156
|
+
const holder = (0, sitesettings_1.collectionAliasConflict)(root, alias, name);
|
|
157
|
+
if (holder) {
|
|
158
|
+
(0, auth_1.apiError)(res, 409, `the alias ${alias} is already how the collection ${holder} is reached`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
(0, sitesettings_1.setCollectionAlias)(root, name, alias);
|
|
163
|
+
res.json({ name, siteAlias: (0, sitesettings_1.storedCollectionAlias)(root, name), alias: (0, sitesettings_1.collectionSiteAlias)(root, name) });
|
|
164
|
+
});
|
|
165
|
+
/** Whether the vault holds a collection of this name; the rename's own check. */
|
|
166
|
+
function isCollection(name) {
|
|
140
167
|
try {
|
|
141
|
-
|
|
168
|
+
return fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
|
|
142
169
|
}
|
|
143
170
|
catch {
|
|
144
|
-
|
|
171
|
+
return false;
|
|
145
172
|
}
|
|
146
|
-
|
|
147
|
-
|
|
173
|
+
}
|
|
174
|
+
// Only an empty one: what "empty" means, and what goes with the directory,
|
|
175
|
+
// is deleteCollection's business, shared with the web route.
|
|
176
|
+
app.delete('/api/collections/:name', (req, res) => {
|
|
177
|
+
const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
|
|
178
|
+
if (!auth)
|
|
148
179
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// collection's own.
|
|
153
|
-
const repos = (0, layout_1.reposDir)(root, name);
|
|
154
|
-
const own = fs.readdirSync(dir).filter((n) => n !== layout_1.REPOS_DIR);
|
|
155
|
-
const inRepos = fs.existsSync(repos) ? fs.readdirSync(repos) : [];
|
|
156
|
-
if (own.length > 0 || inRepos.length > 0) {
|
|
157
|
-
(0, auth_1.apiError)(res, 409, `collection ${name} is not empty`);
|
|
180
|
+
const name = req.params.name;
|
|
181
|
+
if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
|
|
182
|
+
(0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
|
|
158
183
|
return;
|
|
159
184
|
}
|
|
160
185
|
try {
|
|
161
|
-
|
|
186
|
+
(0, ops_1.deleteCollection)(root, name);
|
|
162
187
|
}
|
|
163
188
|
catch (e) {
|
|
164
|
-
(0, auth_1.
|
|
189
|
+
(0, auth_1.sendOpError)(res, e, 'could not remove the collection');
|
|
165
190
|
return;
|
|
166
191
|
}
|
|
167
|
-
// Any redirect that led here goes with it. A collection created later
|
|
168
|
-
// under this name would otherwise inherit the traffic a former name of
|
|
169
|
-
// this one still sends.
|
|
170
|
-
(0, redirects_1.forgetCollectionRedirects)(root, name);
|
|
171
192
|
res.json({ deleted: name });
|
|
172
193
|
});
|
|
173
194
|
// ---- users and their tokens ----
|
|
195
|
+
// A token-scoped token reaches its globs and nothing else, and in
|
|
196
|
+
// particular administers nothing (src/perms.ts). Its own user's token list
|
|
197
|
+
// is something to administer: a scoped token handed to a script could
|
|
198
|
+
// otherwise list and revoke the unrestricted tokens beside it, locking the
|
|
199
|
+
// user out of git and the CLI.
|
|
200
|
+
function requireUnscoped(auth, res) {
|
|
201
|
+
if (!(0, perms_1.tokenIsScoped)(auth))
|
|
202
|
+
return true;
|
|
203
|
+
(0, auth_1.apiError)(res, 403, 'a token-scoped token may not list or revoke tokens');
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
174
206
|
app.get('/api/users/:name', (req, res) => {
|
|
175
207
|
const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
|
|
176
208
|
if (!auth)
|
|
@@ -191,6 +223,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
|
|
|
191
223
|
(0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
|
|
192
224
|
return;
|
|
193
225
|
}
|
|
226
|
+
if (!requireUnscoped(auth, res))
|
|
227
|
+
return;
|
|
194
228
|
res.json({
|
|
195
229
|
name: req.params.name,
|
|
196
230
|
siteAdmin: user.siteAdmin === true,
|
|
@@ -215,6 +249,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
|
|
|
215
249
|
(0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
|
|
216
250
|
return;
|
|
217
251
|
}
|
|
252
|
+
if (!requireUnscoped(auth, res))
|
|
253
|
+
return;
|
|
218
254
|
// Never the token, and never the hash either: an id is what revocation
|
|
219
255
|
// takes, and the hash is a credential-shaped thing with no reason to travel.
|
|
220
256
|
res.json({
|
|
@@ -240,6 +276,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
|
|
|
240
276
|
(0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
|
|
241
277
|
return;
|
|
242
278
|
}
|
|
279
|
+
if (!requireUnscoped(auth, res))
|
|
280
|
+
return;
|
|
243
281
|
let result;
|
|
244
282
|
try {
|
|
245
283
|
result = (0, vault_1.revokeToken)(root, req.params.name, req.params.id);
|
package/dist/api/auth.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.bodyOf = bodyOf;
|
|
|
10
10
|
exports.stringField = stringField;
|
|
11
11
|
exports.stringsField = stringsField;
|
|
12
12
|
exports.limitParam = limitParam;
|
|
13
|
+
const naming_1 = require("../naming");
|
|
13
14
|
const ops_1 = require("../ops");
|
|
14
15
|
const perms_1 = require("../perms");
|
|
15
16
|
const scan_1 = require("../scan");
|
|
@@ -38,11 +39,11 @@ function apiError(res, status, message) {
|
|
|
38
39
|
function requireApiAuth(root, limiter, req, res) {
|
|
39
40
|
const state = (0, vault_1.loadVault)(root);
|
|
40
41
|
if (state.status === 'missing') {
|
|
41
|
-
apiError(res, 401,
|
|
42
|
+
apiError(res, 401, `no ${naming_1.naming.stateFile} in this ${naming_1.naming.rootNoun}; restart the server to initialize one`);
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
45
|
if (state.status === 'error') {
|
|
45
|
-
apiError(res, 500,
|
|
46
|
+
apiError(res, 500, `${naming_1.naming.stateFile} could not be read: ${state.message}`);
|
|
46
47
|
return null;
|
|
47
48
|
}
|
|
48
49
|
const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);
|