@magland/mochi 0.3.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.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
package/dist/forms.js ADDED
@@ -0,0 +1,737 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flashBanner = flashBanner;
4
+ exports.loginPage = loginPage;
5
+ exports.newRepoPage = newRepoPage;
6
+ exports.forkPage = forkPage;
7
+ exports.importPage = importPage;
8
+ exports.newCollectionPage = newCollectionPage;
9
+ exports.collectionSettingsPage = collectionSettingsPage;
10
+ exports.editFilePage = editFilePage;
11
+ exports.newFilePage = newFilePage;
12
+ exports.uploadPage = uploadPage;
13
+ exports.deleteFilePage = deleteFilePage;
14
+ exports.conflictPage = conflictPage;
15
+ exports.settingsPage = settingsPage;
16
+ exports.adminUsersPage = adminUsersPage;
17
+ exports.adminUserPage = adminUserPage;
18
+ exports.adminShell = adminShell;
19
+ exports.adminIndexPage = adminIndexPage;
20
+ exports.egressPage = egressPage;
21
+ exports.appearancePage = appearancePage;
22
+ exports.tokenPage = tokenPage;
23
+ exports.opErrorPage = opErrorPage;
24
+ const avatar_1 = require("./avatar");
25
+ const html_1 = require("./html");
26
+ const icons_1 = require("./icons");
27
+ const logo_1 = require("./logo");
28
+ const render_1 = require("./render");
29
+ const perms_1 = require("./perms");
30
+ const vault_1 = require("./vault");
31
+ const views_1 = require("./views");
32
+ // Form pages for the UI operations. Every mutating form embeds the session's
33
+ // CSRF value and posts back to a handler that re-checks authorization against
34
+ // live vault.json.
35
+ function errorBanner(error) {
36
+ return error ? (0, html_1.html) `<div class="form-error">${error}</div>` : '';
37
+ }
38
+ function flashBanner(msg) {
39
+ return msg ? (0, html_1.html) `<div class="flash">${msg}</div>` : '';
40
+ }
41
+ function loginPage(next, error) {
42
+ // A narrow card under the mark, centred on the page: signing in is the one
43
+ // thing this page is for, so nothing else is on it.
44
+ const content = (0, html_1.html) `<div class="signin">
45
+ <div class="signin-mark">${(0, html_1.raw)(logo_1.MARK)}</div>
46
+ <h1>Sign in to Mochi Forge</h1>
47
+ ${errorBanner(error)}
48
+ <div class="form-box">
49
+ <form method="post" action="/login">
50
+ <input type="hidden" name="next" value="${next}">
51
+ <div class="field"><label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" autofocus required></div>
52
+ <div class="field"><label for="token">Token</label><input type="password" id="token" name="token" autocomplete="current-password" required></div>
53
+ <button type="submit" class="btn btn-primary">Sign in</button>
54
+ </form>
55
+ </div>
56
+ <p class="muted small signin-note">A token is what git uses for pushing. Tokens are minted by an administrator; there are no passwords.</p>
57
+ </div>`;
58
+ return (0, views_1.layout)('Sign in', content, { path: '/login' });
59
+ }
60
+ function newRepoPage(viewer, collectionNames, preset, error) {
61
+ const datalist = collectionNames.map((o) => (0, html_1.html) `<option value="${o}">`);
62
+ const content = (0, html_1.html) `<div class="form-box wide">
63
+ <h1>Create a new repository</h1>
64
+ <p class="muted">A repository is a directory in the vault holding a bare git repository. Its name and the collection it sits in are its address.</p>
65
+ <hr class="rule">
66
+ ${errorBanner(error)}
67
+ <form method="post" action="/new">
68
+ ${(0, views_1.csrfField)(viewer)}
69
+ <div class="name-row">
70
+ <div class="field"><label for="collection">Collection</label><input type="text" id="collection" name="collection" list="collections" value="${preset.collection ?? ''}" required><datalist id="collections">${datalist}</datalist></div>
71
+ <div class="name-slash">/</div>
72
+ <div class="field"><label for="name">Repository name</label><input type="text" id="name" name="name" value="${preset.name ?? ''}" required></div>
73
+ </div>
74
+ <p class="muted small">The collection may be one that exists or a new one to create along with the repository.</p>
75
+ <div class="field"><label for="description">Description <span class="muted">(optional)</span></label><input type="text" id="description" name="description" value="${preset.description ?? ''}"></div>
76
+ <hr class="rule">
77
+ <div class="field"><label class="checkbox"><input type="checkbox" name="init" value="1" checked> Initialize with a README</label>
78
+ <p class="muted small">Gives the repository a first commit on <span class="mono">main</span>, so it can be browsed and cloned straight away.</p></div>
79
+ <div class="field"><label class="checkbox"><input type="checkbox" name="private" value="1"> Private</label>
80
+ <p class="muted small">A private repository is visible only to its collaborators, the collection's owners, and site admins. Everything else in a vault reads anonymously.</p></div>
81
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('repo')}<span>Create repository</span></button>
82
+ </form>
83
+ </div>`;
84
+ return (0, views_1.layout)('New repository', content, { viewer, path: '/new' });
85
+ }
86
+ /**
87
+ * The fork form. GitHub asks where the copy should go and lets the name be
88
+ * changed on the way; so does this, with the collection defaulting to one
89
+ * named after the signed-in user, which is the shape most vaults have.
90
+ */
91
+ function forkPage(ctx, viewer, collectionNames, preset, error) {
92
+ const base = (0, views_1.repoUrl)(ctx);
93
+ const datalist = collectionNames.map((o) => (0, html_1.html) `<option value="${o}">`);
94
+ const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
95
+ <div class="form-box wide">
96
+ <h1>Fork ${ctx.collection}/${ctx.repo}</h1>
97
+ <p class="muted">A fork is a copy of the repository somewhere else in this vault. Its objects are shared with the original on disk until one of them gains new ones, so a fork costs almost nothing.</p>
98
+ <hr class="rule">
99
+ ${errorBanner(error)}
100
+ <form method="post" action="${base}/fork">
101
+ ${(0, views_1.csrfField)(viewer)}
102
+ <div class="name-row">
103
+ <div class="field"><label for="collection">Collection</label><input type="text" id="collection" name="collection" list="collections" value="${preset.collection ?? ''}" required><datalist id="collections">${datalist}</datalist></div>
104
+ <div class="name-slash">/</div>
105
+ <div class="field"><label for="name">Repository name</label><input type="text" id="name" name="name" value="${preset.name ?? ctx.repo}" required></div>
106
+ </div>
107
+ <p class="muted small">You need permission to create in the destination collection: your own, one you own, or anywhere for a site admin. Branches and tags come across; issues, releases, and workflow runs stay with the original.</p>
108
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('repo-forked')}<span>Create fork</span></button>
109
+ </form>
110
+ </div>`;
111
+ return (0, views_1.layout)(`Fork ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${base}/fork`));
112
+ }
113
+ /**
114
+ * The import page. Importing has always run on the reader's machine rather than
115
+ * on the server, and what this page does is hand them the command
116
+ * that does it. Earlier it asked for the source in a form and wrote a shell
117
+ * one-liner from the answers, which put a form in front of an operation the
118
+ * page cannot perform; now `mochi import` performs it, so the page only has
119
+ * to say what to run, with the collection and the vault's own URL filled in.
120
+ * The git commands remain below for a machine with no Node on it.
121
+ */
122
+ function importPage(viewer, opts) {
123
+ const collection = opts.collection ?? 'mycollection';
124
+ const back = opts.collection
125
+ ? (0, html_1.html) `<p><a class="btn" href="/${encodeURIComponent(opts.collection)}">Back to ${opts.collection}</a></p>`
126
+ : '';
127
+ const fallback = opts.gitCommand
128
+ ? (0, html_1.html) `<hr class="rule">
129
+ <h2>Without the CLI</h2>
130
+ <p class="muted small">The same two steps by hand. Replace the source URL, and the name after the collection if you want one other than the source's.</p>
131
+ ${(0, views_1.copyRow)(opts.gitCommand)}`
132
+ : '';
133
+ const content = (0, html_1.html) `<div class="form-box wide">
134
+ <h1>Import a repository</h1>
135
+ <p class="muted">Importing runs on your machine, not on this server: git reads the source with the credentials you already have there and pushes it here, which creates the repository. The <span class="mono">mochi</span> command does both.</p>
136
+ <hr class="rule">
137
+ <h2>Once per machine</h2>
138
+ ${(0, views_1.copyRow)('npm install -g @magland/mochi')}
139
+ ${(0, views_1.copyRow)(`mochi login ${opts.vaultUrl}`)}
140
+ <h2>Then, for each repository</h2>
141
+ ${(0, views_1.copyRow)(`mochi import https://github.com/owner/repo ${collection}`)}
142
+ <p class="muted small">The source may be an https or ssh git URL, or <span class="mono">owner/repo</span> for GitHub. The repository takes its name from the source; write <span class="mono">${collection}/another-name</span> to choose another. Add <span class="mono">--lfs</span> to carry Git LFS objects too. A collection that does not exist yet is created by the push. A public GitHub source also has its description read from GitHub and set here.</p>
143
+ ${fallback}
144
+ ${back}
145
+ </div>`;
146
+ return (0, views_1.layout)('Import a repository', content, { viewer, path: '/import' });
147
+ }
148
+ /**
149
+ * Creating a collection on its own. A push creates the collection it lands in,
150
+ * so this is for the order where the collection comes first: an empty one to
151
+ * import into, or to hand someone push access over before anything is in it.
152
+ */
153
+ function newCollectionPage(viewer, preset, error) {
154
+ const content = (0, html_1.html) `<div class="form-box wide">
155
+ <h1>Create a new collection</h1>
156
+ <p class="muted">A collection is a directory in the vault holding repositories. It may be empty; repositories arrive by creation, import, or push.</p>
157
+ <hr class="rule">
158
+ ${errorBanner(error)}
159
+ <form method="post" action="/new/collection">
160
+ ${(0, views_1.csrfField)(viewer)}
161
+ <div class="field"><label for="name">Collection name</label><input type="text" id="name" name="name" value="${preset.name ?? ''}" required autofocus></div>
162
+ <p class="muted small">Letters, digits, dot, underscore, and dash. The collection named after you is yours to create; any other name takes a site admin.</p>
163
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('plus')}<span>Create collection</span></button>
164
+ </form>
165
+ </div>`;
166
+ return (0, views_1.layout)('New collection', content, { viewer, path: '/new/collection' });
167
+ }
168
+ /**
169
+ * A collection's own settings, which today is one operation: its name.
170
+ *
171
+ * Repository settings live under the repository, so a collection's live under
172
+ * the collection, at the same place in the path. The rename is graded amber
173
+ * rather than red for the same reason a repository's is: what it breaks can be
174
+ * put back by renaming it again.
175
+ */
176
+ function collectionSettingsPage(viewer, collection, repoCount, owners, msg, error) {
177
+ const base = `/${encodeURIComponent(collection)}`;
178
+ const holds = repoCount === 0
179
+ ? 'This collection is empty.'
180
+ : `Every one of its ${repoCount === 1 ? 'repository' : `${repoCount} repositories`} moves with it, along with their sites, workflow runs, issues, pull requests, releases, and LFS objects.`;
181
+ const content = (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 28, 'square')}${collection}</h1></div>
182
+ <h2>Settings</h2>
183
+ ${flashBanner(msg)}
184
+ ${errorBanner(error)}
185
+ <div class="box settings-box"><div class="box-header">${(0, icons_1.icon)('people')}Owners</div><div class="box-body">
186
+ <p class="muted small">Owners hold the admin role on every repository in the collection, may create repositories in it,
187
+ and manage these settings. A user named <span class="mono">${collection}</span> owns it by bearing its name and is not
188
+ listed here.</p>
189
+ ${owners.length
190
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>User</th><th class="right"></th></tr>${owners.map((o) => (0, html_1.html) `<tr><td class="with-avatar">${(0, avatar_1.avatar)(o, 24)}<b>${o}</b></td><td class="right">
191
+ <form method="post" action="${base}/settings/owners/remove" class="inline-form">
192
+ ${(0, views_1.csrfField)(viewer)}
193
+ <input type="hidden" name="username" value="${o}">
194
+ <button type="submit" class="btn btn-danger-outline">Remove</button>
195
+ </form></td></tr>`)}</tbody></table>`
196
+ : (0, html_1.html) `<p class="muted">No owners are listed.</p>`}
197
+ <form method="post" action="${base}/settings/owners" class="inline-form">
198
+ ${(0, views_1.csrfField)(viewer)}
199
+ <label for="ownerUser">User</label><input type="text" id="ownerUser" name="username" required>
200
+ <button type="submit" class="btn">${(0, icons_1.icon)('people')}<span>Add owner</span></button>
201
+ </form>
202
+ </div></div>
203
+ <div class="danger-zone caution">
204
+ <h3>Rename</h3>
205
+ <p>${holds} The old address is redirected here, so links and existing clones keep working until something else is created under that name, but token scopes naming the old collection have to be granted again under the new name.</p>
206
+ <form method="post" action="${base}/settings/rename" class="inline-form">
207
+ ${(0, views_1.csrfField)(viewer)}
208
+ <label for="toName">Collection name</label><input type="text" id="toName" name="name" value="${collection}" required>
209
+ <button type="submit" class="btn">${(0, icons_1.icon)('pencil')}<span>Rename</span></button>
210
+ </form>
211
+ </div>`;
212
+ return (0, views_1.layout)(`Settings - ${collection}`, content, {
213
+ crumbs: (0, html_1.html) ` / <a href="${base}">${collection}</a>`,
214
+ viewer,
215
+ path: `${base}/settings`,
216
+ });
217
+ }
218
+ /**
219
+ * The commit box: a summary and an optional extended description, which is
220
+ * GitHub's shape and git's own (the two are joined by a blank line before the
221
+ * commit is made). The face beside the heading is whose commit it will be.
222
+ */
223
+ function commitFields(viewer, expectedHead, messagePlaceholder, branch) {
224
+ // Committing to a new branch is GitHub's second choice in this box, and the
225
+ // one that keeps a shared branch clean. The name field is only read when
226
+ // that choice is made, so leaving it filled in is harmless.
227
+ const branchChoice = branch === undefined
228
+ ? ''
229
+ : (0, html_1.html) `<div class="commit-target">
230
+ <label class="checkbox"><input type="checkbox" name="newBranchWanted" value="1"> Commit to a new branch</label>
231
+ <div class="field"><label class="sr-only" for="newBranch">New branch name</label><input type="text" id="newBranch" name="newBranch" placeholder="${branch}-patch" autocomplete="off"></div>
232
+ <p class="muted small">Leave it unticked to commit straight to <span class="mono">${branch}</span>.</p>
233
+ </div>`;
234
+ return (0, html_1.html) `${(0, views_1.csrfField)(viewer)}
235
+ <input type="hidden" name="expected" value="${expectedHead ?? ''}">
236
+ <div class="commit-box-head">${(0, avatar_1.avatar)(viewer.auth.username, 28)}<b>Commit changes</b></div>
237
+ <div class="field"><label class="sr-only" for="message">Commit message</label><input type="text" id="message" name="message" placeholder="${messagePlaceholder}"></div>
238
+ <div class="field"><label class="sr-only" for="description">Extended description</label><textarea id="description" name="description" rows="3" placeholder="Add an optional extended description"></textarea></div>
239
+ ${branchChoice}`;
240
+ }
241
+ function editFilePage(ctx, filePath, content, expectedHead, error) {
242
+ const base = (0, views_1.repoUrl)(ctx);
243
+ const action = `${base}/edit/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
244
+ const cancel = `${base}/blob/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
245
+ const rows = Math.min(30, Math.max(12, content.split('\n').length + 2));
246
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
247
+ <h2 class="file-head">Editing <span class="mono">${filePath}</span> on <span class="mono">${ctx.ref}</span></h2>
248
+ ${errorBanner(error)}
249
+ <form method="post" action="${action}">
250
+ <div class="field"><label for="path">Path</label><input type="text" id="path" name="path" value="${filePath}" spellcheck="false"><p class="muted small">Changing it renames or moves the file in the same commit.</p></div>
251
+ <textarea class="code-editor" name="content" rows="${rows}" spellcheck="false">${content}</textarea>
252
+ <div class="commit-box">
253
+ ${commitFields(ctx.viewer, expectedHead, `Update ${filePath.split('/').pop()}`, ctx.ref)}
254
+ <div class="actions"><button type="submit" class="btn btn-primary">Commit changes</button><a class="btn" href="${cancel}">Cancel</a></div>
255
+ </div>
256
+ </form>`;
257
+ return (0, views_1.layout)(`Editing ${filePath} - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
258
+ }
259
+ function newFilePage(ctx, branch, dir, expectedHead, preset, error) {
260
+ const base = (0, views_1.repoUrl)(ctx);
261
+ const action = `${base}/new/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
262
+ const cancel = expectedHead === null ? base : `${base}/tree/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
263
+ const prefix = dir === '' ? '' : `${dir}/`;
264
+ const branchNote = expectedHead === null
265
+ ? (0, html_1.html) `<p class="muted small">This repository is empty; committing will create the <span class="mono">${branch}</span> branch.</p>`
266
+ : '';
267
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
268
+ <h2 class="file-head">New file in <span class="mono">${prefix || '/'}</span> on <span class="mono">${branch}</span></h2>
269
+ ${branchNote}
270
+ ${errorBanner(error)}
271
+ <form method="post" action="${action}">
272
+ <div class="field"><label for="filename">File name</label><div class="filename-row"><span class="mono muted">${prefix}</span><input type="text" id="filename" name="filename" value="${preset.filename ?? ''}" placeholder="path/to/file.md" required></div></div>
273
+ <textarea class="code-editor" name="content" rows="18" spellcheck="false">${preset.content ?? ''}</textarea>
274
+ <div class="commit-box">
275
+ ${commitFields(ctx.viewer, expectedHead, 'Create new file', expectedHead === null ? undefined : branch)}
276
+ <div class="actions"><button type="submit" class="btn btn-primary">Commit new file</button><a class="btn" href="${cancel}">Cancel</a></div>
277
+ </div>
278
+ </form>`;
279
+ return (0, views_1.layout)(`New file - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
280
+ }
281
+ /**
282
+ * The upload form. A file input and the same commit box as the editor, so
283
+ * uploading is the same act as writing a file: a commit with a message, on
284
+ * this branch or on a new one.
285
+ */
286
+ function uploadPage(ctx, branch, dir, expectedHead, maxBytes, error) {
287
+ const base = (0, views_1.repoUrl)(ctx);
288
+ const action = `${base}/upload/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
289
+ const cancel = `${base}/tree/${(0, views_1.encPath)(branch)}${dir === '' ? '' : `/${(0, views_1.encPath)(dir)}`}`;
290
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
291
+ <h2 class="file-head">Uploading to <span class="mono">${dir === '' ? '/' : `${dir}/`}</span> on <span class="mono">${branch}</span></h2>
292
+ ${errorBanner(error)}
293
+ <form method="post" action="${action}" enctype="multipart/form-data">
294
+ <div class="field"><label for="files">Files</label>
295
+ <input type="file" id="files" name="files" multiple required>
296
+ <p class="muted small">Up to ${Math.floor(maxBytes / (1024 * 1024))} MB in one commit. A file that is already there is replaced, keeping its mode. Large binaries are better pushed with Git LFS.</p></div>
297
+ <div class="commit-box">
298
+ ${commitFields(ctx.viewer, expectedHead, 'Add files via upload', branch)}
299
+ <div class="actions"><button type="submit" class="btn btn-primary">${(0, icons_1.icon)('upload')}<span>Commit changes</span></button><a class="btn" href="${cancel}">Cancel</a></div>
300
+ </div>
301
+ </form>`;
302
+ return (0, views_1.layout)(`Upload to ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx, action));
303
+ }
304
+ function deleteFilePage(ctx, filePath, expectedHead, error) {
305
+ const base = (0, views_1.repoUrl)(ctx);
306
+ const action = `${base}/delete/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
307
+ const cancel = `${base}/blob/${(0, views_1.encPath)(ctx.ref)}/${(0, views_1.encPath)(filePath)}`;
308
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
309
+ <h2 class="file-head">Delete <span class="mono">${filePath}</span> from <span class="mono">${ctx.ref}</span></h2>
310
+ ${errorBanner(error)}
311
+ <div class="form-box">
312
+ <p>This commits the removal of <b>${filePath}</b> to the <b>${ctx.ref}</b> branch. The file stays in the history.</p>
313
+ <form method="post" action="${action}">
314
+ ${commitFields(ctx.viewer, expectedHead, `Delete ${filePath.split('/').pop()}`)}
315
+ <div class="actions"><button type="submit" class="btn btn-danger">Delete file</button><a class="btn" href="${cancel}">Cancel</a></div>
316
+ </form>
317
+ </div>`;
318
+ return (0, views_1.layout)(`Delete ${filePath} - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
319
+ }
320
+ function conflictPage(ctx, branch, retryUrl) {
321
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'code')}
322
+ <div class="form-box">
323
+ <h2>The branch has moved</h2>
324
+ <p>Someone updated <b>${branch}</b> while you were editing, so your change was not committed; committing it now could silently undo theirs.</p>
325
+ <p><a class="btn btn-primary" href="${retryUrl}">Reload and try again</a></p>
326
+ </div>`;
327
+ return (0, views_1.layout)(`Conflict - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx));
328
+ }
329
+ function settingsPage(ctx, description, access, msg, error) {
330
+ const base = (0, views_1.repoUrl)(ctx);
331
+ const branchOptions = ctx.branches.map((b) => (0, html_1.html) `<option value="${b.name}"${b.name === ctx.defaultBranch ? (0, html_1.raw)(' selected') : ''}>${b.name}</option>`);
332
+ const defaultBranchField = ctx.branches.length > 0
333
+ ? (0, html_1.html) `<div class="field"><label for="defaultBranch">Default branch</label><select id="defaultBranch" name="defaultBranch">${branchOptions}</select></div>`
334
+ : '';
335
+ const settingsForm = ctx.canPush
336
+ ? (0, html_1.html) `<div class="box settings-box"><div class="box-header">${(0, icons_1.icon)('sliders')}General</div><div class="box-body">
337
+ <form method="post" action="${base}/settings">
338
+ ${(0, views_1.csrfField)(ctx.viewer)}
339
+ <div class="field"><label for="description">Description</label><input type="text" id="description" name="description" value="${description}"><p class="muted small">Shown beside the repository in listings and in the About panel.</p></div>
340
+ ${defaultBranchField}
341
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
342
+ </form>
343
+ </div></div>`
344
+ : '';
345
+ // Who may see and write the repository. Admin only, like the rename below:
346
+ // access is the repository's own business.
347
+ const collaboratorRows = access.collaborators.map(({ username, role }) => (0, html_1.html) `<tr><td class="with-avatar">${(0, avatar_1.avatar)(username, 24)}<b>${username}</b></td><td class="muted">${role}</td><td class="right">
348
+ <form method="post" action="${base}/settings/collaborators/remove" class="inline-form">
349
+ ${(0, views_1.csrfField)(ctx.viewer)}
350
+ <input type="hidden" name="username" value="${username}">
351
+ <button type="submit" class="btn btn-danger-outline">Remove</button>
352
+ </form></td></tr>`);
353
+ const ownersNote = access.owners.length
354
+ ? (0, html_1.html) `<p class="muted small">Owners of <span class="mono">${ctx.collection}</span> (${(0, html_1.joinHtml)(access.owners.map((o) => (0, html_1.html) `<b>${o}</b>`), ', ')}, and the user the collection is named after) hold the admin role here without being listed.</p>`
355
+ : (0, html_1.html) `<p class="muted small">The user the collection is named after, and any owners added to it, hold the admin role here without being listed.</p>`;
356
+ const accessBox = ctx.canAdmin
357
+ ? (0, html_1.html) `<div class="box settings-box"><div class="box-header">${(0, icons_1.icon)('people')}Access</div><div class="box-body">
358
+ <form method="post" action="${base}/settings/visibility">
359
+ ${(0, views_1.csrfField)(ctx.viewer)}
360
+ <input type="hidden" name="private" value="${ctx.isPrivate ? 'false' : 'true'}">
361
+ <p>${ctx.isPrivate
362
+ ? (0, html_1.html) `This repository is <b>private</b>: visible to its collaborators, the collection's owners, and site admins, on the web, over git, and in the API alike.${ctx.hasSite
363
+ ? (0, html_1.html) ` Its published <a href="${ctx.siteUrl}">site</a> stays world-readable; remove the site directory if the site must go too.`
364
+ : ''}`
365
+ : (0, html_1.html) `This repository is <b>public</b>: anyone can read and clone it, as everything in a vault is by default.`}</p>
366
+ <button type="submit" class="btn">${ctx.isPrivate ? 'Make public' : 'Make private'}</button>
367
+ </form>
368
+ <hr class="rule">
369
+ <h3>Collaborators</h3>
370
+ ${collaboratorRows.length
371
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>User</th><th>Role</th><th class="right"></th></tr>${collaboratorRows}</tbody></table>`
372
+ : (0, html_1.html) `<p class="muted">No collaborators.</p>`}
373
+ ${ownersNote}
374
+ <form method="post" action="${base}/settings/collaborators" class="inline-form">
375
+ ${(0, views_1.csrfField)(ctx.viewer)}
376
+ <label for="collabUser">User</label><input type="text" id="collabUser" name="username" required>
377
+ <label for="collabRole">Role</label><select id="collabRole" name="role">
378
+ <option value="read">read</option>
379
+ <option value="write" selected>write</option>
380
+ <option value="admin">admin</option>
381
+ </select>
382
+ <button type="submit" class="btn">${(0, icons_1.icon)('people')}<span>Add</span></button>
383
+ </form>
384
+ <p class="muted small">read may see a private repository; write may also push and edit; admin may also change its settings, visibility, and collaborators.</p>
385
+ </div></div>`
386
+ : '';
387
+ // Renaming and moving are one operation, since both are a directory rename.
388
+ // It is flagged because every URL to this repository, and every remote
389
+ // pointing at it, changes with it; it takes the amber grade rather than the
390
+ // red one because what it breaks can be put back by renaming it again.
391
+ const renameForm = ctx.canAdmin
392
+ ? (0, html_1.html) `<div class="danger-zone caution">
393
+ <h3>Rename or move</h3>
394
+ <p>Everything moves with the repository: its site, its workflow runs, its issues, its releases, and its LFS objects. The old address is redirected here, so links and existing clones keep working, until something else is created under that name.</p>
395
+ <form method="post" action="${base}/settings/rename" class="inline-form">
396
+ ${(0, views_1.csrfField)(ctx.viewer)}
397
+ <label for="toCollection">Collection</label><input type="text" id="toCollection" name="collection" value="${ctx.collection}" required>
398
+ <label for="toName">Name</label><input type="text" id="toName" name="name" value="${ctx.repo}" required>
399
+ <button type="submit" class="btn">${(0, icons_1.icon)('pencil')}<span>Rename</span></button>
400
+ </form>
401
+ </div>`
402
+ : '';
403
+ const dangerZone = ctx.canAdmin
404
+ ? (0, html_1.html) `<div class="danger-zone">
405
+ <h3>Danger zone</h3>
406
+ <p>Deleting a repository removes its directory${ctx.hasSite ? ' and its site' : ''} from the vault permanently. There is no undo.</p>
407
+ <form method="post" action="${base}/settings/delete">
408
+ ${(0, views_1.csrfField)(ctx.viewer)}
409
+ <div class="field"><label for="confirm">Type <b class="mono">${ctx.collection}/${ctx.repo}</b> to confirm</label><input type="text" id="confirm" name="confirm" autocomplete="off"></div>
410
+ <button type="submit" class="btn btn-danger">${(0, icons_1.icon)('trash')}<span>Delete this repository</span></button>
411
+ </form>
412
+ </div>`
413
+ : '';
414
+ const body = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'settings')}
415
+ <h2>Settings</h2>
416
+ ${flashBanner(msg)}
417
+ ${errorBanner(error)}
418
+ ${settingsForm}
419
+ ${accessBox}
420
+ ${renameForm}
421
+ ${dangerZone}`;
422
+ return (0, views_1.layout)(`Settings - ${ctx.collection}/${ctx.repo}`, body, (0, views_1.repoOpts)(ctx, `${base}/settings`));
423
+ }
424
+ function adminUsersPage(viewer, users, msg, error) {
425
+ const rows = users.map(({ name, user }) => {
426
+ const actions = (0, html_1.html) `<details class="dropdown"><summary class="btn">${(0, icons_1.icon)('kebab')}<span>Manage</span></summary><div class="dropdown-menu dd-right user-actions">
427
+ <form method="post" action="/admin/users/${encodeURIComponent(name)}/grant" class="inline-form">
428
+ ${(0, views_1.csrfField)(viewer)}
429
+ <input type="hidden" name="siteAdmin" value="${user.siteAdmin ? 'false' : 'true'}">
430
+ <button type="submit" class="btn">${user.siteAdmin ? 'Withdraw site admin' : 'Make site admin'}</button>
431
+ </form>
432
+ <form method="post" action="/admin/users/${encodeURIComponent(name)}/token" class="inline-form">
433
+ ${(0, views_1.csrfField)(viewer)}
434
+ <input type="text" name="tokenScope" placeholder="token scope (optional)">
435
+ <button type="submit" class="btn">Mint token</button>
436
+ </form>
437
+ </div></details>`;
438
+ const userUrl = `/admin/users/${encodeURIComponent(name)}`;
439
+ return (0, html_1.html) `<tr><td class="with-avatar">${(0, avatar_1.avatar)(name, 24)}<a href="${userUrl}"><b>${name}</b></a></td><td class="muted small">${user.siteAdmin ? 'site admin' : ''}</td><td class="right muted small"><a href="${userUrl}">${user.tokens.length} token${user.tokens.length === 1 ? '' : 's'}</a></td><td>${actions}</td></tr>`;
440
+ });
441
+ const content = (0, html_1.html) `<h1>Users</h1>
442
+ ${flashBanner(msg)}
443
+ ${errorBanner(error)}
444
+ <table class="listing"><tbody><tr><th>User</th><th>Site admin</th><th class="right">Tokens</th><th></th></tr>${rows}</tbody></table>
445
+ <div class="form-box" style="margin-top:24px">
446
+ <h2>Add user</h2>
447
+ <form method="post" action="/admin/users">
448
+ ${(0, views_1.csrfField)(viewer)}
449
+ <div class="field"><label for="username">Username</label><input type="text" id="username" name="username" required>
450
+ <p class="muted small">A user owns the collection named after them: they create repositories there, administer them, and
451
+ may grant others access. Everything else is granted per repository (collaborators) or per collection (owners).</p></div>
452
+ <div class="field"><label><input type="checkbox" name="siteAdmin" value="true"> Site admin</label>
453
+ <p class="muted small">Site admins hold the admin role everywhere and manage users, runners, and the vault's settings.</p></div>
454
+ <button type="submit" class="btn btn-primary">Create user and mint token</button>
455
+ </form>
456
+ <p class="muted small">The new token is shown once on the next page.</p>
457
+ </div>`;
458
+ return adminShell(viewer, 'users', 'Users', '/admin/users', content);
459
+ }
460
+ /**
461
+ * One user: their scopes, each token they hold, and the way to take either
462
+ * away. The tokens are the point of the page. Every write to a vault is
463
+ * authorized by a bearer token, so a leaked one needs an off switch that does
464
+ * not involve editing vault.json by hand, and this is it: revocation reads
465
+ * live on the next request, for git and for the web session alike, since both
466
+ * resolve the token against vault.json every time.
467
+ */
468
+ function adminUserPage(viewer, name, user, msg, error) {
469
+ const self = name === viewer.auth.username;
470
+ const base = `/admin/users/${encodeURIComponent(name)}`;
471
+ const tokenRows = user.tokens.map((t) => {
472
+ const id = (0, vault_1.tokenId)(t);
473
+ const current = self && (0, vault_1.tokenId)(viewer.auth.token) === id;
474
+ const revoke = (0, html_1.html) `<form method="post" action="${base}/tokens/${encodeURIComponent(id)}/revoke" class="inline-form">
475
+ ${(0, views_1.csrfField)(viewer)}
476
+ <button type="submit" class="btn btn-danger-outline">Revoke</button>
477
+ </form>`;
478
+ return (0, html_1.html) `<tr><td class="mono">${id}${current ? (0, html_1.raw)(' <span class="counter">this session</span>') : ''}</td><td class="muted small">${t.created ? (0, render_1.timeTag)(t.created, '') : (0, html_1.raw)('<span class="muted">before minting was dated</span>')}</td><td class="mono small">${t.scope?.join(' ') ?? ''}${t.scope ? '' : (0, html_1.raw)('<span class="muted">the user’s scope</span>')}</td><td class="right">${revoke}</td></tr>`;
479
+ });
480
+ const tokens = user.tokens.length > 0
481
+ ? (0, html_1.html) `<table class="listing"><tbody><tr><th>Token</th><th>Minted</th><th>Scope</th><th class="right"></th></tr>${tokenRows}</tbody></table>
482
+ <p class="muted small">Only a SHA-256 hash of each token is stored, so there is nothing to show beyond its id. Revocation
483
+ is immediate: the next push and the next page load are both refused. Revoking the token this session was signed in with
484
+ signs you out.</p>`
485
+ : (0, html_1.html) `<div class="empty-state">No tokens. This user cannot push or sign in until one is minted.</div>`;
486
+ const mint = (0, html_1.html) `<div class="form-box">
487
+ <h2>Mint a token</h2>
488
+ <form method="post" action="${base}/token">
489
+ ${(0, views_1.csrfField)(viewer)}
490
+ <input type="hidden" name="next" value="${base}">
491
+ <div class="field"><label for="tokenScope">Token scope <span class="muted">(optional)</span></label>
492
+ <input type="text" id="tokenScope" name="tokenScope" placeholder="e.g. mycollection/*">
493
+ <p class="muted small">Globs that narrow this one token below the user's own scope. A token with a scope of its own
494
+ carries no admin rights. Leave empty for a token as broad as the user.</p></div>
495
+ <button type="submit" class="btn btn-primary">Mint token</button>
496
+ </form>
497
+ <p class="muted small">Minting does not revoke anything: the user's other tokens keep working until each is revoked here.</p>
498
+ </div>`;
499
+ const grant = (0, html_1.html) `<div class="form-box">
500
+ <h2>Site admin</h2>
501
+ <form method="post" action="${base}/grant">
502
+ ${(0, views_1.csrfField)(viewer)}
503
+ <input type="hidden" name="next" value="${base}">
504
+ <input type="hidden" name="siteAdmin" value="${user.siteAdmin ? 'false' : 'true'}">
505
+ <p>${user.siteAdmin
506
+ ? (0, html_1.html) `<b>${name}</b> is a site admin: the admin role everywhere, plus users, runners, and the vault's settings.`
507
+ : (0, html_1.html) `<b>${name}</b> owns the collection <span class="mono">${name}</span> by name; anything more is granted per
508
+ repository (collaborators), per collection (owners), or with the site-admin bit here.`}</p>
509
+ <button type="submit" class="btn">${user.siteAdmin ? 'Withdraw site admin' : 'Make site admin'}</button>
510
+ </form>
511
+ </div>`;
512
+ const identity = (0, html_1.html) `<div class="form-box">
513
+ <h2>Identity</h2>
514
+ <form method="post" action="${base}/emails">
515
+ ${(0, views_1.csrfField)(viewer)}
516
+ <div class="field"><label for="emails">Git author emails</label>
517
+ <input type="text" id="emails" name="emails" value="${(user.emails ?? []).join(' ')}" placeholder="e.g. jane@example.org">
518
+ <p class="muted small">Commits by these addresses are attributed to ${name}: one contributor, one face, however they
519
+ were authored. Edits made in the web interface as ${name} are attributed automatically. Space-separated; saving an
520
+ empty list clears it.</p></div>
521
+ <button type="submit" class="btn">Save emails</button>
522
+ </form>
523
+ </div>`;
524
+ const danger = self
525
+ ? (0, html_1.html) `<p class="muted small">A user cannot delete themselves; another administrator can, or edit <span class="mono">vault.json</span> by hand.</p>`
526
+ : (0, html_1.html) `<div class="danger-zone">
527
+ <h3>Danger zone</h3>
528
+ <p>Deleting a user revokes every token they hold, immediately. Nothing they pushed is touched: commits, issues, and
529
+ comments keep their name.</p>
530
+ <form method="post" action="${base}/delete">
531
+ ${(0, views_1.csrfField)(viewer)}
532
+ <div class="field"><label for="confirm">Type <b class="mono">${name}</b> to confirm</label><input type="text" id="confirm" name="confirm" autocomplete="off"></div>
533
+ <button type="submit" class="btn btn-danger">${(0, icons_1.icon)('trash')}<span>Delete this user</span></button>
534
+ </form>
535
+ </div>`;
536
+ const content = (0, html_1.html) `<div class="page-head"><div class="with-avatar">${(0, avatar_1.avatar)(name, 32)}<h1>${name}</h1></div></div>
537
+ ${flashBanner(msg)}
538
+ ${errorBanner(error)}
539
+ <p class="muted">${user.siteAdmin ? 'Site admin' : (0, html_1.html) `Owns the collection <span class="mono">${name}</span> by name`}</p>
540
+ <h2>Tokens</h2>
541
+ ${tokens}
542
+ ${mint}
543
+ ${grant}
544
+ ${identity}
545
+ ${danger}
546
+ <p><a href="/admin/users">&larr; All users</a></p>`;
547
+ return adminShell(viewer, 'users', `${name} - Users`, base, content);
548
+ }
549
+ /**
550
+ * The shell every administration page sits in: the sections down the left, as
551
+ * GitHub's settings pages have. It saves each page a "Back" link and makes it
552
+ * plain what else there is to administer.
553
+ */
554
+ function adminShell(viewer, active, title, path, body) {
555
+ const item = (id, href, label, glyph) => (0, html_1.html) `<a class="${active === id ? 'current' : ''}" href="${href}">${(0, icons_1.icon)(glyph)}<span>${label}</span></a>`;
556
+ // Every admin page is the site admin's now, but the shell keeps the check
557
+ // so a page added for a weaker audience later hides what is not theirs.
558
+ const canVault = (0, perms_1.isSiteAdmin)(viewer.auth);
559
+ const nav = (0, html_1.html) `<aside class="admin-side"><div class="side-block"><h3>${(0, icons_1.icon)('sliders')}Administration</h3><div class="side-links">
560
+ ${item('users', '/admin/users', 'Users', 'people')}
561
+ ${item('runners', '/admin/runners', 'Runners', 'server')}
562
+ ${canVault ? item('egress', '/admin/egress', 'Egress', 'upload') : ''}
563
+ ${canVault ? item('appearance', '/admin/appearance', 'Appearance', 'appearance') : ''}
564
+ </div></div></aside>`;
565
+ // The bar locates an admin page the way it locates a repository page: the
566
+ // trail up from here, for after the heading has scrolled away.
567
+ const sections = {
568
+ index: null,
569
+ users: { href: '/admin/users', label: 'Users' },
570
+ runners: { href: '/admin/runners', label: 'Runners' },
571
+ egress: { href: '/admin/egress', label: 'Egress' },
572
+ appearance: { href: '/admin/appearance', label: 'Appearance' },
573
+ };
574
+ const section = sections[active];
575
+ const crumbs = (0, html_1.html) ` / <a href="/admin">admin</a>${section ? (0, html_1.html) ` / <a href="${section.href}">${section.label}</a>` : ''}`;
576
+ return (0, views_1.layout)(title, (0, html_1.html) `<div class="admin-layout">${nav}<div class="admin-main">${body}</div></div>`, {
577
+ viewer,
578
+ path,
579
+ crumbs,
580
+ });
581
+ }
582
+ function adminIndexPage(viewer, canVault) {
583
+ const card = (href, title, blurb) => (0, html_1.html) `<a class="card" href="${href}"><b>${title}</b><span class="muted small">${blurb}</span></a>`;
584
+ const content = (0, html_1.html) `<h1>Administration</h1>
585
+ <div class="card-list">
586
+ ${card('/admin/users', 'Users', 'Create users, grant site admin, mint tokens.')}
587
+ ${card('/admin/runners', 'Runners', 'Register the machines that execute workflow jobs.')}
588
+ ${canVault
589
+ ? card('/admin/egress', 'Egress', 'See what has been sent out today, per repository, and cap what a day may send.')
590
+ : ''}
591
+ ${canVault
592
+ ? card('/admin/appearance', 'Appearance', 'Choose the theme this vault is served with.')
593
+ : ''}
594
+ </div>
595
+ ${canVault
596
+ ? ''
597
+ : (0, html_1.html) `<p class="muted small">Appearance and the egress budget are vault-wide settings, so they are the site admin's.</p>`}`;
598
+ return adminShell(viewer, 'index', 'Administration', '/admin', content);
599
+ }
600
+ /**
601
+ * What the vault has sent out today, and the cap on it.
602
+ *
603
+ * The page a bill sends someone looking, so it answers the two questions in
604
+ * that order: how much has gone out today and which repository it went for,
605
+ * then how to bound it. The breakdown is the honest part of the design and the
606
+ * cap is the useful one, and both are on one page because a number without a
607
+ * lever is just worrying.
608
+ */
609
+ function egressPage(viewer, snap, opts) {
610
+ const capped = snap.capBytes > 0;
611
+ const share = capped ? Math.min(1, snap.total / snap.capBytes) : 0;
612
+ // Amber before the vault stops answering rather than after, since the point
613
+ // of looking at this page is to act before it does.
614
+ const level = !capped ? 'off' : share >= 1 ? 'over' : share >= 0.8 ? 'near' : 'under';
615
+ const meter = capped
616
+ ? (0, html_1.html) `<div class="egress-meter"><span class="fill ${level}" style="width:${(share * 100).toFixed(1)}%"></span></div>`
617
+ : '';
618
+ const hours = Math.floor(snap.resetsIn / 3600);
619
+ const mins = Math.round((snap.resetsIn % 3600) / 60);
620
+ const resets = hours > 0 ? `${hours}h ${mins}m` : `${Math.max(1, mins)}m`;
621
+ const banner = snap.overBudget
622
+ ? (0, html_1.html) `<div class="form-error">This vault has reached its daily limit and is refusing ordinary requests. Administration pages
623
+ and signing in keep working. The counters reset at 00:00 UTC, in ${resets}; raising the limit below takes effect
624
+ immediately.</div>`
625
+ : '';
626
+ const rows = snap.rows.length > 0
627
+ ? snap.rows.map((r) => {
628
+ const bracketed = r.repo.startsWith('(');
629
+ const name = bracketed
630
+ ? (0, html_1.html) `<span class="muted">${r.repo}</span>`
631
+ : (0, html_1.html) `<a href="/${(0, views_1.encPath)(r.repo)}">${r.repo}</a>`;
632
+ const what = r.site ? (0, html_1.raw)('<span class="counter">site</span>') : '';
633
+ return (0, html_1.html) `<tr><td>${name} ${what}</td><td class="right mono">${(0, render_1.formatSize)(r.bytes)}</td></tr>`;
634
+ })
635
+ : (0, html_1.raw)('<tr><td colspan="2" class="muted">Nothing has gone out yet today.</td></tr>');
636
+ const history = snap.history.length > 0
637
+ ? (0, html_1.html) `<h2>Earlier days</h2>
638
+ <table class="listing"><tbody><tr><th>Day (UTC)</th><th class="right">Out</th></tr>
639
+ ${snap.history.map((d) => (0, html_1.html) `<tr><td class="mono">${d.day}</td><td class="right mono">${(0, render_1.formatSize)(d.total)}</td></tr>`)}
640
+ </tbody></table>
641
+ <p class="muted small">Up to 30 days are kept, in <span class="mono">egress.json</span> at the root of the vault.</p>`
642
+ : '';
643
+ const content = (0, html_1.html) `<div class="page-head"><h1>Egress</h1></div>
644
+ ${flashBanner(opts.msg)}
645
+ ${banner}
646
+ <p class="muted">Bytes this server has written to clients, counted per repository and totalled per UTC day. A site served
647
+ from its own hostname is counted against the repository it belongs to, on a row of its own. Bytes that belong to no
648
+ repository are two rows: <span class="mono">(vault)</span> for the vault's own pages and API, and
649
+ <span class="mono">(unmatched)</span> for requests that named a repository this vault does not have.</p>
650
+ <div class="egress-total">
651
+ <b class="mono">${(0, render_1.formatSize)(snap.total)}</b>
652
+ <span class="muted">${capped ? `of ${(0, render_1.formatSize)(snap.capBytes)} today` : 'today, with no limit set'}</span>
653
+ </div>
654
+ ${meter}
655
+ <p class="muted small">${snap.day} UTC. Resets in ${resets}.</p>
656
+
657
+ <table class="listing"><tbody><tr><th>Repository</th><th class="right">Out today</th></tr>${rows}</tbody></table>
658
+
659
+ <h2>Daily limit</h2>
660
+ <div class="form-box">
661
+ <form method="post" action="/admin/egress">
662
+ ${(0, views_1.csrfField)(viewer)}
663
+ <div class="field">
664
+ <label for="egressGbPerDay">Gigabytes per day</label>
665
+ <input type="number" id="egressGbPerDay" name="egressGbPerDay" min="0" step="any" value="${snap.capGb}">
666
+ <p class="muted small">Once a day's total reaches this, every ordinary request is refused with a 503 until 00:00 UTC.
667
+ Administration pages, signing in, and the stylesheet they need keep working, within a further 64 MB, so that the limit
668
+ can be raised from here. 0 sends without a limit.</p>
669
+ </div>
670
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save limit</span></button>
671
+ </form>
672
+ </div>
673
+
674
+ ${history}
675
+
676
+ <h2>What is not counted</h2>
677
+ <ul class="muted small">
678
+ <li>Bytes are counted as they are written to the socket, so they include response headers and are compressed already.
679
+ What a host meters will be a little higher, since it also carries TCP and TLS framing.</li>
680
+ ${opts.lfsBucket
681
+ ? (0, html_1.html) `<li>Git LFS objects are served from the configured bucket through presigned URLs, so those downloads never pass
682
+ through this server and are not in these numbers; the bucket's own metering is where they show up. The limit still
683
+ reaches them, one step earlier: a client has to ask this server for a presigned URL before it can fetch anything, and
684
+ that request is refused like any other once the day's budget is spent. Only URLs signed in the previous hour keep
685
+ working.</li>`
686
+ : ''}
687
+ <li>Counts are written to disk at most every 30 seconds, so a hard kill can lose up to that much. Two servers sharing
688
+ one vault add their counts together at each write, and each may send up to 30 seconds past the limit before it sees the
689
+ other's.</li>
690
+ </ul>`;
691
+ return adminShell(viewer, 'egress', 'Egress', '/admin/egress', content);
692
+ }
693
+ function appearancePage(viewer, themes, current, msg) {
694
+ const cards = themes.map((t) => {
695
+ // The colours are the theme's own tokens, from themes.ts, so they are
696
+ // markup rather than data: a swatch is the one place the interface paints
697
+ // with a palette other than the active one.
698
+ const v = t.vars;
699
+ const swatch = (0, html_1.raw)(`<div class="theme-swatch" style="background:${v.bg}">
700
+ <div class="bar" style="background:${v.surface};border:1px solid ${v.border}"></div>
701
+ <div class="row"><span class="dot" style="background:${v.accent}"></span><span class="dot" style="background:${v.primary}"></span><span class="dot" style="background:${v.tabMarker}"></span><span style="color:${v.fg};font-size:12px;font-family:${v.fontHead}">Aa</span><span style="color:${v.fgMuted};font-size:12px">muted</span></div>
702
+ </div>`);
703
+ return (0, html_1.html) `<div class="theme-card${t.name === current ? ' current' : ''}">
704
+ <label>
705
+ ${swatch}
706
+ <div class="theme-meta">
707
+ <span class="name"><input type="radio" name="theme" value="${t.name}"${t.name === current ? (0, html_1.raw)(' checked') : ''}> ${t.label}${t.dark ? (0, html_1.raw)(' <span class="counter">dark</span>') : ''}</span>
708
+ <p class="muted small">${t.blurb}</p>
709
+ </div>
710
+ </label>
711
+ </div>`;
712
+ });
713
+ const content = (0, html_1.html) `<div class="page-head"><h1>Appearance</h1></div>
714
+ ${flashBanner(msg)}
715
+ <p class="muted">The theme applies to the whole vault, for every visitor. It is stored in <span class="mono">config.json</span> next to <span class="mono">vault.json</span>, so it can also be set by hand.</p>
716
+ <form method="post" action="/admin/appearance">
717
+ ${(0, views_1.csrfField)(viewer)}
718
+ <div class="theme-grid">${cards}</div>
719
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save theme</span></button>
720
+ </form>`;
721
+ return adminShell(viewer, 'appearance', 'Appearance', '/admin/appearance', content);
722
+ }
723
+ function tokenPage(viewer, username, token, created) {
724
+ const heading = created ? `Created user ${username}` : `New token for ${username}`;
725
+ const content = (0, html_1.html) `<div class="form-box">
726
+ <h1>${heading}</h1>
727
+ <p>Copy the token now; only its SHA-256 hash is stored, so it cannot be shown again.</p>
728
+ <div class="cmd-row"><code>${token}</code>${(0, views_1.copyButton)()}</div>
729
+ <p class="muted small">Use it as the password with username <b>${username}</b> when git asks for credentials, or to sign in here.</p>
730
+ <p><a class="btn" href="/admin/users">Back to users</a></p>
731
+ </div>`;
732
+ return (0, views_1.layout)(heading, content, { viewer, path: '/admin/users' });
733
+ }
734
+ function opErrorPage(message, opts = {}) {
735
+ const back = opts.backUrl ? (0, html_1.html) `<p><a class="btn" href="${opts.backUrl}">Go back</a></p>` : '';
736
+ return (0, views_1.layout)('Error', (0, html_1.html) `<div class="form-box"><h1>That did not work</h1><div class="form-error">${message}</div>${back}</div>`, opts);
737
+ }