@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.
- package/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/perms.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
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.COLLECTION_FILE = exports.REPO_ACCESS_FILE = void 0;
|
|
37
|
+
exports.atLeast = atLeast;
|
|
38
|
+
exports.repoAccess = repoAccess;
|
|
39
|
+
exports.repoIsPrivate = repoIsPrivate;
|
|
40
|
+
exports.setRepoPrivate = setRepoPrivate;
|
|
41
|
+
exports.setCollaborator = setCollaborator;
|
|
42
|
+
exports.removeCollaborator = removeCollaborator;
|
|
43
|
+
exports.collectionOwners = collectionOwners;
|
|
44
|
+
exports.isCollectionOwner = isCollectionOwner;
|
|
45
|
+
exports.addCollectionOwner = addCollectionOwner;
|
|
46
|
+
exports.removeCollectionOwner = removeCollectionOwner;
|
|
47
|
+
exports.isSiteAdmin = isSiteAdmin;
|
|
48
|
+
exports.repoRole = repoRole;
|
|
49
|
+
exports.canReadRepo = canReadRepo;
|
|
50
|
+
exports.canWriteRepo = canWriteRepo;
|
|
51
|
+
exports.canAdminRepo = canAdminRepo;
|
|
52
|
+
exports.canCreateRepo = canCreateRepo;
|
|
53
|
+
exports.canCreateSomeRepo = canCreateSomeRepo;
|
|
54
|
+
exports.canCreateCollection = canCreateCollection;
|
|
55
|
+
exports.canAdminCollection = canAdminCollection;
|
|
56
|
+
exports.repoRenameBlocker = repoRenameBlocker;
|
|
57
|
+
exports.collectionMoveBlocker = collectionMoveBlocker;
|
|
58
|
+
exports.removeUserGrants = removeUserGrants;
|
|
59
|
+
exports.canAdminRunnerGlobs = canAdminRunnerGlobs;
|
|
60
|
+
const fs = __importStar(require("fs"));
|
|
61
|
+
const path = __importStar(require("path"));
|
|
62
|
+
const atomic_1 = require("./atomic");
|
|
63
|
+
const filecache_1 = require("./filecache");
|
|
64
|
+
const layout_1 = require("./layout");
|
|
65
|
+
const scan_1 = require("./scan");
|
|
66
|
+
const vault_1 = require("./vault");
|
|
67
|
+
const RANK = { read: 1, write: 2, admin: 3 };
|
|
68
|
+
function atLeast(role, min) {
|
|
69
|
+
return role !== null && RANK[role] >= RANK[min];
|
|
70
|
+
}
|
|
71
|
+
/** The strongest of two, where null is weakest of all. */
|
|
72
|
+
function stronger(a, b) {
|
|
73
|
+
if (a === null)
|
|
74
|
+
return b;
|
|
75
|
+
if (b === null)
|
|
76
|
+
return a;
|
|
77
|
+
return RANK[a] >= RANK[b] ? a : b;
|
|
78
|
+
}
|
|
79
|
+
function isRole(v) {
|
|
80
|
+
return v === 'read' || v === 'write' || v === 'admin';
|
|
81
|
+
}
|
|
82
|
+
exports.REPO_ACCESS_FILE = 'mochi.json';
|
|
83
|
+
function normalizeRepoAccess(parsed) {
|
|
84
|
+
const out = { private: false, collaborators: {} };
|
|
85
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
86
|
+
return out;
|
|
87
|
+
const rec = parsed;
|
|
88
|
+
if (rec.private === true)
|
|
89
|
+
out.private = true;
|
|
90
|
+
if (typeof rec.collaborators === 'object' && rec.collaborators !== null) {
|
|
91
|
+
for (const [user, role] of Object.entries(rec.collaborators)) {
|
|
92
|
+
if (isRole(role))
|
|
93
|
+
out.collaborators[user] = role;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const accessCache = (0, filecache_1.fileCache)({
|
|
99
|
+
read: (file) => {
|
|
100
|
+
try {
|
|
101
|
+
return normalizeRepoAccess(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// An unreadable file grants nothing it cannot prove: the repository is
|
|
105
|
+
// treated as private with no collaborators, so a corrupted access file
|
|
106
|
+
// fails closed rather than publishing what it was protecting.
|
|
107
|
+
return { private: true, collaborators: {} };
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
missing: () => ({ private: false, collaborators: {} }),
|
|
111
|
+
});
|
|
112
|
+
/** The access state of the repository whose bare directory this is. */
|
|
113
|
+
function repoAccess(repoDir) {
|
|
114
|
+
return accessCache.get(path.join(repoDir, exports.REPO_ACCESS_FILE));
|
|
115
|
+
}
|
|
116
|
+
function repoIsPrivate(repoDir) {
|
|
117
|
+
return repoAccess(repoDir).private;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read-modify-write on a repository's access file, under a lock, the same
|
|
121
|
+
* arrangement vault.json edits use. The cache is invalidated explicitly
|
|
122
|
+
* because a write landing within the mtime granularity would otherwise be
|
|
123
|
+
* invisible to it.
|
|
124
|
+
*/
|
|
125
|
+
function editRepoAccess(repoDir, fn) {
|
|
126
|
+
const file = path.join(repoDir, exports.REPO_ACCESS_FILE);
|
|
127
|
+
return (0, atomic_1.withFileLock)(`${file}.lock`, () => {
|
|
128
|
+
let access = { private: false, collaborators: {} };
|
|
129
|
+
if (fs.existsSync(file)) {
|
|
130
|
+
access = normalizeRepoAccess(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
131
|
+
}
|
|
132
|
+
fn(access);
|
|
133
|
+
(0, atomic_1.writeFileAtomic)(file, JSON.stringify(access, null, 2) + '\n');
|
|
134
|
+
accessCache.invalidate(file);
|
|
135
|
+
return access;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function setRepoPrivate(repoDir, priv) {
|
|
139
|
+
return editRepoAccess(repoDir, (a) => {
|
|
140
|
+
a.private = priv;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function setCollaborator(repoDir, username, role) {
|
|
144
|
+
return editRepoAccess(repoDir, (a) => {
|
|
145
|
+
a.collaborators[username] = role;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function removeCollaborator(repoDir, username) {
|
|
149
|
+
return editRepoAccess(repoDir, (a) => {
|
|
150
|
+
delete a.collaborators[username];
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ---- per-collection state: collections/<name>/collection.json ----
|
|
154
|
+
exports.COLLECTION_FILE = 'collection.json';
|
|
155
|
+
function collectionFilePath(root, collection) {
|
|
156
|
+
return path.join((0, layout_1.collectionDir)(root, collection), exports.COLLECTION_FILE);
|
|
157
|
+
}
|
|
158
|
+
function normalizeOwners(parsed) {
|
|
159
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
160
|
+
return [];
|
|
161
|
+
const owners = parsed.owners;
|
|
162
|
+
if (!Array.isArray(owners))
|
|
163
|
+
return [];
|
|
164
|
+
return owners.filter((o) => typeof o === 'string');
|
|
165
|
+
}
|
|
166
|
+
const ownersCache = (0, filecache_1.fileCache)({
|
|
167
|
+
read: (file) => {
|
|
168
|
+
try {
|
|
169
|
+
return normalizeOwners(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
missing: () => [],
|
|
176
|
+
});
|
|
177
|
+
/** The explicit owners of a collection. The implicit one is not listed here. */
|
|
178
|
+
function collectionOwners(root, collection) {
|
|
179
|
+
return ownersCache.get(collectionFilePath(root, collection));
|
|
180
|
+
}
|
|
181
|
+
/** Owner by the explicit list or by bearing the collection's own name. */
|
|
182
|
+
function isCollectionOwner(root, collection, username) {
|
|
183
|
+
return username === collection || collectionOwners(root, collection).includes(username);
|
|
184
|
+
}
|
|
185
|
+
function editOwners(root, collection, fn) {
|
|
186
|
+
const file = collectionFilePath(root, collection);
|
|
187
|
+
// The collection directory exists whenever a collection does; a caller
|
|
188
|
+
// adding an owner to a collection that is yet to exist creates it. Before
|
|
189
|
+
// the lock, because the lock file lives in the same directory.
|
|
190
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
191
|
+
return (0, atomic_1.withFileLock)(`${file}.lock`, () => {
|
|
192
|
+
let owners = [];
|
|
193
|
+
if (fs.existsSync(file)) {
|
|
194
|
+
owners = normalizeOwners(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
195
|
+
}
|
|
196
|
+
owners = fn(owners);
|
|
197
|
+
(0, atomic_1.writeFileAtomic)(file, JSON.stringify({ owners }, null, 2) + '\n');
|
|
198
|
+
ownersCache.invalidate(file);
|
|
199
|
+
return owners;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function addCollectionOwner(root, collection, username) {
|
|
203
|
+
return editOwners(root, collection, (owners) => (owners.includes(username) ? owners : [...owners, username]));
|
|
204
|
+
}
|
|
205
|
+
function removeCollectionOwner(root, collection, username) {
|
|
206
|
+
return editOwners(root, collection, (owners) => owners.filter((o) => o !== username));
|
|
207
|
+
}
|
|
208
|
+
function tokenCovers(auth, target) {
|
|
209
|
+
return auth.token.scope === undefined || auth.token.scope.some((g) => (0, vault_1.globMatch)(g, target));
|
|
210
|
+
}
|
|
211
|
+
function tokenIsScoped(auth) {
|
|
212
|
+
return auth.token.scope !== undefined;
|
|
213
|
+
}
|
|
214
|
+
/** Site admin, acting through a token that has not been narrowed. */
|
|
215
|
+
function isSiteAdmin(auth) {
|
|
216
|
+
return auth !== null && auth.user.siteAdmin === true && !tokenIsScoped(auth);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The strongest role the caller holds on this repository, or null for none.
|
|
220
|
+
* null on a private repository is "you cannot see it", and the surfaces
|
|
221
|
+
* answer it with the same 404 an absent repository gets.
|
|
222
|
+
*/
|
|
223
|
+
function repoRole(root, auth, repo) {
|
|
224
|
+
const access = repoAccess(repo.dir);
|
|
225
|
+
const anonymous = access.private ? null : 'read';
|
|
226
|
+
if (!auth)
|
|
227
|
+
return anonymous;
|
|
228
|
+
let role = auth.user.siteAdmin === true || isCollectionOwner(root, repo.collection, auth.username)
|
|
229
|
+
? 'admin'
|
|
230
|
+
: stronger(anonymous, access.collaborators[auth.username] ?? null);
|
|
231
|
+
if (tokenIsScoped(auth)) {
|
|
232
|
+
if (!tokenCovers(auth, `${repo.collection}/${repo.name}`))
|
|
233
|
+
return anonymous;
|
|
234
|
+
if (role === 'admin')
|
|
235
|
+
role = 'write';
|
|
236
|
+
}
|
|
237
|
+
return role;
|
|
238
|
+
}
|
|
239
|
+
function canReadRepo(root, auth, repo) {
|
|
240
|
+
return atLeast(repoRole(root, auth, repo), 'read');
|
|
241
|
+
}
|
|
242
|
+
function canWriteRepo(root, auth, repo) {
|
|
243
|
+
return atLeast(repoRole(root, auth, repo), 'write');
|
|
244
|
+
}
|
|
245
|
+
function canAdminRepo(root, auth, repo) {
|
|
246
|
+
return atLeast(repoRole(root, auth, repo), 'admin');
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether a user may bring the repository collection/name into being, which
|
|
250
|
+
* push-to-create, the new-repository form, forks, and repository moves all
|
|
251
|
+
* ask. Owners create in their collections; site admins anywhere. A narrowed
|
|
252
|
+
* token must additionally cover the name being created, as it always had to.
|
|
253
|
+
*/
|
|
254
|
+
function canCreateRepo(root, auth, collection, name) {
|
|
255
|
+
if (!tokenCovers(auth, `${collection}/${name}`))
|
|
256
|
+
return false;
|
|
257
|
+
return auth.user.siteAdmin === true || isCollectionOwner(root, collection, auth.username);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The same question with no repository name yet: whether the user could
|
|
261
|
+
* create anything at all in the collection. The import page asks this before
|
|
262
|
+
* writing a push command it knows would be refused.
|
|
263
|
+
*/
|
|
264
|
+
function canCreateSomeRepo(root, auth, collection) {
|
|
265
|
+
if (!(auth.user.siteAdmin === true || isCollectionOwner(root, collection, auth.username)))
|
|
266
|
+
return false;
|
|
267
|
+
if (auth.token.scope === undefined)
|
|
268
|
+
return true;
|
|
269
|
+
const collectionOf = (glob) => (glob.includes('/') ? glob.slice(0, glob.indexOf('/')) : glob);
|
|
270
|
+
return auth.token.scope.some((g) => (0, vault_1.globMatch)(collectionOf(g), collection));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether a user may create the collection named. A user's own namespace is
|
|
274
|
+
* theirs the way a GitHub account's is; every other name takes a site admin.
|
|
275
|
+
* The empty-collection form and repository moves into a fresh collection ask
|
|
276
|
+
* this; push-to-create asks canCreateRepo, which folds the same rule in
|
|
277
|
+
* through ownership.
|
|
278
|
+
*/
|
|
279
|
+
function canCreateCollection(_root, auth, collection) {
|
|
280
|
+
if (auth.user.siteAdmin === true && !tokenIsScoped(auth))
|
|
281
|
+
return true;
|
|
282
|
+
if (auth.username !== collection)
|
|
283
|
+
return false;
|
|
284
|
+
// A narrowed token may create its holder's namespace only if some glob's
|
|
285
|
+
// collection part reaches into it.
|
|
286
|
+
if (auth.token.scope === undefined)
|
|
287
|
+
return true;
|
|
288
|
+
const collectionOf = (glob) => (glob.includes('/') ? glob.slice(0, glob.indexOf('/')) : glob);
|
|
289
|
+
return auth.token.scope.some((g) => (0, vault_1.globMatch)(collectionOf(g), collection));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Whether a user may administer the collection as a whole: its settings, its
|
|
293
|
+
* owners, its name. Owners and site admins may; a narrowed token may not
|
|
294
|
+
* administer anything, as before.
|
|
295
|
+
*/
|
|
296
|
+
function canAdminCollection(root, auth, collection) {
|
|
297
|
+
if (tokenIsScoped(auth))
|
|
298
|
+
return false;
|
|
299
|
+
return auth.user.siteAdmin === true || isCollectionOwner(root, collection, auth.username);
|
|
300
|
+
}
|
|
301
|
+
// The composite rules below are each asked by the web interface and by the
|
|
302
|
+
// JSON API. They live here, once, so the two surfaces cannot drift; the
|
|
303
|
+
// blockers return the missing ability as a phrase rather than answering
|
|
304
|
+
// false, so each surface words its own refusal around the same rule.
|
|
305
|
+
/**
|
|
306
|
+
* The ability a repository rename or move is missing, or null when none is.
|
|
307
|
+
* Renaming in place is the repository's own business, so the admin role there
|
|
308
|
+
* decides it; moving to another collection is also a creation over there, so
|
|
309
|
+
* that half asks canCreateRepo, exactly as a transfer does on GitHub.
|
|
310
|
+
*/
|
|
311
|
+
function repoRenameBlocker(root, auth, repo, toCollection, toName) {
|
|
312
|
+
if (!canAdminRepo(root, auth, repo))
|
|
313
|
+
return `the admin role on ${repo.collection}/${repo.name}`;
|
|
314
|
+
if (toCollection !== repo.collection && !canCreateRepo(root, auth, toCollection, toName)) {
|
|
315
|
+
return `permission to create repositories in ${toCollection}`;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The ability a collection rename is missing, or null when none is. Owners
|
|
321
|
+
* rename their collection to any free name, and the owners file moves with
|
|
322
|
+
* it; whether the destination name is free is the operation's own check, not
|
|
323
|
+
* a permission.
|
|
324
|
+
*/
|
|
325
|
+
function collectionMoveBlocker(root, auth, collection) {
|
|
326
|
+
if (!canAdminCollection(root, auth, collection))
|
|
327
|
+
return `ownership of ${collection}`;
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Remove every grant a username holds, across the vault: its entries in
|
|
332
|
+
* owners lists and its collaborator roles. Called when the user is deleted,
|
|
333
|
+
* because a grant is keyed by name alone: left behind, it would belong to
|
|
334
|
+
* whoever is given the name next, silently. The user's implicit namespace
|
|
335
|
+
* needs no sweeping, since a future user of the same name is meant to own
|
|
336
|
+
* the collection named after them.
|
|
337
|
+
*/
|
|
338
|
+
function removeUserGrants(root, username) {
|
|
339
|
+
for (const { name: collection } of (0, scan_1.listCollections)(root)) {
|
|
340
|
+
if (collectionOwners(root, collection).includes(username)) {
|
|
341
|
+
removeCollectionOwner(root, collection, username);
|
|
342
|
+
}
|
|
343
|
+
for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
|
|
344
|
+
const dir = (0, layout_1.repoPath)(root, collection, dirName);
|
|
345
|
+
if (repoAccess(dir).collaborators[username] !== undefined) {
|
|
346
|
+
removeCollaborator(dir, username);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Whether a user may administer a runner whose allow list this is. Runners
|
|
353
|
+
* are vault infrastructure, so site admins always may; short of that, a
|
|
354
|
+
* runner confined to collections the caller owns is the caller's to manage.
|
|
355
|
+
* An allow glob whose collection part is itself a pattern reaches
|
|
356
|
+
* collections nobody in particular owns, so only a site admin covers it.
|
|
357
|
+
*/
|
|
358
|
+
function canAdminRunnerGlobs(root, auth, globs) {
|
|
359
|
+
if (tokenIsScoped(auth))
|
|
360
|
+
return false;
|
|
361
|
+
if (auth.user.siteAdmin === true)
|
|
362
|
+
return true;
|
|
363
|
+
if (globs.length === 0)
|
|
364
|
+
return false;
|
|
365
|
+
const collectionOf = (glob) => (glob.includes('/') ? glob.slice(0, glob.indexOf('/')) : glob);
|
|
366
|
+
return globs.every((g) => {
|
|
367
|
+
const c = collectionOf(g);
|
|
368
|
+
return !/[*?]/.test(c) && isCollectionOwner(root, c, auth.username);
|
|
369
|
+
});
|
|
370
|
+
}
|
package/dist/pointer.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Strict parser for Git LFS pointer files. A pointer is exactly the version
|
|
3
|
+
// line, an oid line, and a size line, LF-terminated, in that order. Strictness
|
|
4
|
+
// matters: this parser decides whether a blob renders as a download card
|
|
5
|
+
// instead of as its contents, and a false positive would hide a legitimate
|
|
6
|
+
// text file.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.looksLikePointer = looksLikePointer;
|
|
9
|
+
exports.parsePointer = parsePointer;
|
|
10
|
+
const VERSION_LINE = 'version https://git-lfs.github.com/spec/v1';
|
|
11
|
+
const MAX_POINTER_SIZE = 200;
|
|
12
|
+
// The loose check below reads only the first line, so it can afford a larger
|
|
13
|
+
// bound than the strict parser: a pointer carrying extension lines runs past
|
|
14
|
+
// 200 bytes, and refusing to edit it is the whole point of that check.
|
|
15
|
+
const MAX_LOOSE_POINTER_SIZE = 4096;
|
|
16
|
+
// A deliberately looser test than parsePointer, used only to decide whether
|
|
17
|
+
// the browser editor must refuse a file. Committing text over a pointer
|
|
18
|
+
// corrupts the repository's LFS state, so the refusal must also cover pointers
|
|
19
|
+
// this module will not fully parse: the real format permits extension lines
|
|
20
|
+
// (`ext-0-<name> …`) between the version and the oid, which parsePointer
|
|
21
|
+
// rejects because rendering has to stay strict. Erring toward refusing an edit
|
|
22
|
+
// costs a user one git command; erring the other way silently breaks a file.
|
|
23
|
+
function looksLikePointer(buf) {
|
|
24
|
+
if (buf.length > MAX_LOOSE_POINTER_SIZE)
|
|
25
|
+
return false;
|
|
26
|
+
return buf.toString('utf8', 0, VERSION_LINE.length + 1) === `${VERSION_LINE}\n`;
|
|
27
|
+
}
|
|
28
|
+
function parsePointer(buf) {
|
|
29
|
+
if (buf.length > MAX_POINTER_SIZE)
|
|
30
|
+
return null;
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = buf.toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (!text.endsWith('\n'))
|
|
39
|
+
return null;
|
|
40
|
+
const lines = text.slice(0, -1).split('\n');
|
|
41
|
+
if (lines.length !== 3)
|
|
42
|
+
return null;
|
|
43
|
+
if (lines[0] !== VERSION_LINE)
|
|
44
|
+
return null;
|
|
45
|
+
const oidMatch = lines[1].match(/^oid sha256:([0-9a-f]{64})$/);
|
|
46
|
+
if (!oidMatch)
|
|
47
|
+
return null;
|
|
48
|
+
const sizeMatch = lines[2].match(/^size ([0-9]+)$/);
|
|
49
|
+
if (!sizeMatch)
|
|
50
|
+
return null;
|
|
51
|
+
const size = parseInt(sizeMatch[1], 10);
|
|
52
|
+
if (!Number.isSafeInteger(size))
|
|
53
|
+
return null;
|
|
54
|
+
return { oid: oidMatch[1], size };
|
|
55
|
+
}
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROFILE_DIR = exports.PROFILE_REPO = void 0;
|
|
4
|
+
exports.collectionProfile = collectionProfile;
|
|
5
|
+
const markdown_1 = require("./markdown");
|
|
6
|
+
const render_1 = require("./render");
|
|
7
|
+
const scan_1 = require("./scan");
|
|
8
|
+
const views_1 = require("./views");
|
|
9
|
+
/**
|
|
10
|
+
* A collection's profile README: the page a collection shows above its
|
|
11
|
+
* repository listing, so that a collection can say what it is rather than
|
|
12
|
+
* leaving a visitor to infer it from the names in the list.
|
|
13
|
+
*
|
|
14
|
+
* Where it comes from is a convention rather than a new place in the vault. A
|
|
15
|
+
* repository named `.mochi` in the collection holds it at
|
|
16
|
+
* `profile/README.md`, on the repository's default branch. GitHub reads an
|
|
17
|
+
* organization's profile out of a `.github` repository the same way, and the
|
|
18
|
+
* borrowed shape carries a real advantage: the file is edited, reviewed,
|
|
19
|
+
* diffed, and reverted by everything the forge already does to a file in a
|
|
20
|
+
* repository, and the collection page only reads it.
|
|
21
|
+
*
|
|
22
|
+
* The repository is otherwise ordinary. It appears in the collection's listing
|
|
23
|
+
* like any other, its own README shows on its own page, and the write role on it
|
|
24
|
+
* is what decides who may change the profile.
|
|
25
|
+
*/
|
|
26
|
+
exports.PROFILE_REPO = '.mochi';
|
|
27
|
+
/** The directory inside PROFILE_REPO the README is read from. */
|
|
28
|
+
exports.PROFILE_DIR = 'profile';
|
|
29
|
+
/** The README names accepted in PROFILE_DIR; the first one in the tree wins. */
|
|
30
|
+
const README_RE = /^readme(\.(md|markdown|txt))?$/i;
|
|
31
|
+
const MAX_PROFILE_SIZE = 1024 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Read and render a collection's profile README. Never throws: a collection
|
|
34
|
+
* without the repository, without the file, or with a file that is not text
|
|
35
|
+
* reads as no profile at all, since the collection page is worth serving
|
|
36
|
+
* either way.
|
|
37
|
+
*/
|
|
38
|
+
async function collectionProfile(root, collection) {
|
|
39
|
+
const enc = encodeURIComponent(collection);
|
|
40
|
+
const newRepoUrl = `/new?collection=${enc}&name=${encodeURIComponent(exports.PROFILE_REPO)}`;
|
|
41
|
+
const repo = (0, scan_1.findRepo)(root, collection, exports.PROFILE_REPO);
|
|
42
|
+
if (!repo)
|
|
43
|
+
return { readme: null, addUrl: newRepoUrl };
|
|
44
|
+
const base = `/${enc}/${encodeURIComponent(exports.PROFILE_REPO)}`;
|
|
45
|
+
let branch = null;
|
|
46
|
+
try {
|
|
47
|
+
branch = await repo.defaultBranch(await repo.listRefs('heads'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
branch = null;
|
|
51
|
+
}
|
|
52
|
+
// An empty repository has no branch to read and none to edit on; the editor
|
|
53
|
+
// creates one, and the form's own default is main.
|
|
54
|
+
if (branch === null)
|
|
55
|
+
return { readme: null, addUrl: `${base}/new/main/${exports.PROFILE_DIR}` };
|
|
56
|
+
const addUrl = `${base}/new/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}`;
|
|
57
|
+
const readme = await readProfileReadme(repo, branch);
|
|
58
|
+
if (!readme)
|
|
59
|
+
return { readme: null, addUrl };
|
|
60
|
+
const html = /\.(md|markdown)$/i.test(readme.name) || !readme.name.includes('.')
|
|
61
|
+
? (0, markdown_1.renderMarkdown)(readme.text, {
|
|
62
|
+
rawBase: `${base}/raw/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}`,
|
|
63
|
+
blobBase: `${base}/blob/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}`,
|
|
64
|
+
issueBase: `${base}/issues`,
|
|
65
|
+
commitBase: `${base}/commit`,
|
|
66
|
+
})
|
|
67
|
+
: `<pre>${(0, render_1.esc)(readme.text)}</pre>`;
|
|
68
|
+
return {
|
|
69
|
+
readme: {
|
|
70
|
+
html,
|
|
71
|
+
name: `${exports.PROFILE_DIR}/${readme.name}`,
|
|
72
|
+
url: `${base}/blob/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}/${(0, views_1.encPath)(readme.name)}`,
|
|
73
|
+
},
|
|
74
|
+
addUrl,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function readProfileReadme(repo, branch) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = await repo.listTree(branch, exports.PROFILE_DIR);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// The name is matched the way a repository's own README is, so that a
|
|
86
|
+
// profile written as README.markdown or readme.txt is not silently ignored.
|
|
87
|
+
const entry = entries.find((e) => e.type === 'blob' && README_RE.test(e.name));
|
|
88
|
+
if (!entry || (entry.size ?? 0) > MAX_PROFILE_SIZE)
|
|
89
|
+
return null;
|
|
90
|
+
let buf;
|
|
91
|
+
try {
|
|
92
|
+
buf = await repo.catBlob(branch, `${exports.PROFILE_DIR}/${entry.name}`);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if ((0, render_1.isBinary)(buf))
|
|
98
|
+
return null;
|
|
99
|
+
const text = buf.toString('utf8');
|
|
100
|
+
// A file with nothing in it is no profile: an empty box above the listing
|
|
101
|
+
// would say less than the listing alone, and the prompt to write one is
|
|
102
|
+
// still worth offering.
|
|
103
|
+
if (text.trim() === '')
|
|
104
|
+
return null;
|
|
105
|
+
return { name: entry.name, text };
|
|
106
|
+
}
|