@shard-for-obsidian/lib 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/dist/client/FetchAdapter.d.ts +14 -0
- package/dist/client/FetchAdapter.d.ts.map +1 -0
- package/dist/client/FetchAdapter.js +1 -0
- package/dist/client/OciRegistryClient.d.ts +196 -0
- package/dist/client/OciRegistryClient.d.ts.map +1 -0
- package/dist/client/OciRegistryClient.js +704 -0
- package/dist/client/RegistryClientOptions.d.ts +18 -0
- package/dist/client/RegistryClientOptions.d.ts.map +1 -0
- package/dist/client/RegistryClientOptions.js +1 -0
- package/dist/errors/RegistryErrors.d.ts +39 -0
- package/dist/errors/RegistryErrors.d.ts.map +1 -0
- package/dist/errors/RegistryErrors.js +52 -0
- package/dist/ghcr/GhcrConstants.d.ts +5 -0
- package/dist/ghcr/GhcrConstants.d.ts.map +1 -0
- package/dist/ghcr/GhcrConstants.js +4 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +7 -0
- package/dist/parsing/IndexParser.d.ts +26 -0
- package/dist/parsing/IndexParser.d.ts.map +1 -0
- package/dist/parsing/IndexParser.js +106 -0
- package/dist/parsing/LinkHeaderParser.d.ts +8 -0
- package/dist/parsing/LinkHeaderParser.d.ts.map +1 -0
- package/dist/parsing/LinkHeaderParser.js +34 -0
- package/dist/parsing/RepoParser.d.ts +54 -0
- package/dist/parsing/RepoParser.d.ts.map +1 -0
- package/dist/parsing/RepoParser.js +186 -0
- package/dist/types/AuthTypes.d.ts +14 -0
- package/dist/types/AuthTypes.d.ts.map +1 -0
- package/dist/types/AuthTypes.js +4 -0
- package/dist/types/ManifestTypes.d.ts +98 -0
- package/dist/types/ManifestTypes.d.ts.map +1 -0
- package/dist/types/ManifestTypes.js +7 -0
- package/dist/types/RegistryTypes.d.ts +48 -0
- package/dist/types/RegistryTypes.d.ts.map +1 -0
- package/dist/types/RegistryTypes.js +4 -0
- package/dist/types/RequestTypes.d.ts +23 -0
- package/dist/types/RequestTypes.d.ts.map +1 -0
- package/dist/types/RequestTypes.js +4 -0
- package/dist/utils/DigestUtils.d.ts +9 -0
- package/dist/utils/DigestUtils.d.ts.map +1 -0
- package/dist/utils/DigestUtils.js +26 -0
- package/dist/utils/ValidationUtils.d.ts +2 -0
- package/dist/utils/ValidationUtils.d.ts.map +1 -0
- package/dist/utils/ValidationUtils.js +6 -0
- package/package.json +50 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { urlFromIndex } from "../parsing/IndexParser.js";
|
|
2
|
+
import { parseRepo } from "../parsing/RepoParser.js";
|
|
3
|
+
import { splitIntoTwo } from "../utils/ValidationUtils.js";
|
|
4
|
+
import { encodeHex, digestFromManifestStr } from "../utils/DigestUtils.js";
|
|
5
|
+
import { MEDIATYPE_MANIFEST_V2, MEDIATYPE_MANIFEST_LIST_V2, MEDIATYPE_OCI_MANIFEST_V1, MEDIATYPE_OCI_MANIFEST_INDEX_V1, MEDIATYPE_OBSIDIAN_PLUGIN_CONFIG_V1, } from "../types/ManifestTypes.js";
|
|
6
|
+
import { REALM, SERVICE } from "../ghcr/GhcrConstants.js";
|
|
7
|
+
import * as e from "../errors/RegistryErrors.js";
|
|
8
|
+
import { parseLinkHeader } from "../parsing/LinkHeaderParser.js";
|
|
9
|
+
const DEFAULT_USERAGENT = `open-obsidian-plugin-spec/0.1.0`;
|
|
10
|
+
// Use globalThis.crypto (available in browsers/Electron/Node 18+)
|
|
11
|
+
const getCrypto = () => {
|
|
12
|
+
if (!globalThis.crypto) {
|
|
13
|
+
throw new Error("crypto API not available. This library requires Node.js 18+ or a modern browser environment.");
|
|
14
|
+
}
|
|
15
|
+
return globalThis.crypto;
|
|
16
|
+
};
|
|
17
|
+
/*
|
|
18
|
+
* Set the "Authorization" HTTP header into the headers object from the given
|
|
19
|
+
* auth info.
|
|
20
|
+
* - Bearer auth if `token`.
|
|
21
|
+
* - Else, Basic auth if `username`.
|
|
22
|
+
* - Else, if the authorization key exists, then it is removed from headers.
|
|
23
|
+
*/
|
|
24
|
+
function _setAuthHeaderFromAuthInfo(headers, authInfo) {
|
|
25
|
+
if (authInfo?.type === "Bearer") {
|
|
26
|
+
headers["authorization"] = "Bearer " + authInfo.token;
|
|
27
|
+
}
|
|
28
|
+
else if (authInfo?.type === "Basic") {
|
|
29
|
+
const credentials = `${authInfo.username ?? ""}:${authInfo.password ?? ""}`;
|
|
30
|
+
headers["authorization"] = "Basic " + btoa(credentials);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
delete headers["authorization"];
|
|
34
|
+
}
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Special handling of errors from the registry server.
|
|
39
|
+
*
|
|
40
|
+
* Some registry errors will use a custom error format, so detect those
|
|
41
|
+
* and convert these as necessary.
|
|
42
|
+
*
|
|
43
|
+
* Example JSON response for a missing repo:
|
|
44
|
+
* {
|
|
45
|
+
* "jse_shortmsg": "",
|
|
46
|
+
* "jse_info": {},
|
|
47
|
+
* "message": "{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"message\":\"...}\n",
|
|
48
|
+
* "body": {
|
|
49
|
+
* "errors": [{
|
|
50
|
+
* "code": "UNAUTHORIZED",
|
|
51
|
+
* "message": "authentication required",
|
|
52
|
+
* "detail": [{
|
|
53
|
+
* "Type": "repository",
|
|
54
|
+
* "Class": "",
|
|
55
|
+
* "Name": "library/idontexist",
|
|
56
|
+
* "Action": "pull"
|
|
57
|
+
* }]
|
|
58
|
+
* }]
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* Example JSON response for bad username/password:
|
|
63
|
+
* {
|
|
64
|
+
* "statusCode": 401,
|
|
65
|
+
* "jse_shortmsg":"",
|
|
66
|
+
* "jse_info":{},
|
|
67
|
+
* "message":"{\"details\":\"incorrect username or password\"}\n",
|
|
68
|
+
* "body":{
|
|
69
|
+
* "details": "incorrect username or password"
|
|
70
|
+
* }
|
|
71
|
+
* }
|
|
72
|
+
*
|
|
73
|
+
* Example AWS token error:
|
|
74
|
+
* {
|
|
75
|
+
* "statusCode": 400,
|
|
76
|
+
* "errors": [
|
|
77
|
+
* {
|
|
78
|
+
* "code": "DENIED",
|
|
79
|
+
* "message": "Your Authorization Token is invalid."
|
|
80
|
+
* }
|
|
81
|
+
* ]
|
|
82
|
+
* }
|
|
83
|
+
*/
|
|
84
|
+
function _getRegistryErrorMessage(err) {
|
|
85
|
+
const e = err;
|
|
86
|
+
if (e.body && typeof e.body === "object" && e.body !== null) {
|
|
87
|
+
const body = e.body;
|
|
88
|
+
if (Array.isArray(body.errors) && body.errors[0]) {
|
|
89
|
+
return body.errors[0].message;
|
|
90
|
+
}
|
|
91
|
+
else if (body.details) {
|
|
92
|
+
return body.details;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(e.errors) && e.errors[0]) {
|
|
96
|
+
return e.errors[0].message;
|
|
97
|
+
}
|
|
98
|
+
else if (e.message) {
|
|
99
|
+
return e.message;
|
|
100
|
+
}
|
|
101
|
+
else if (e.details) {
|
|
102
|
+
return e.details;
|
|
103
|
+
}
|
|
104
|
+
return String(err);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Return a scope string to be used for an auth request. Example:
|
|
108
|
+
* repository:library/nginx:pull
|
|
109
|
+
*/
|
|
110
|
+
function _makeAuthScope(resource, name, actions) {
|
|
111
|
+
return `${resource}:${name}:${actions.join(",")}`;
|
|
112
|
+
}
|
|
113
|
+
/*
|
|
114
|
+
* Parse the 'Docker-Content-Digest' header.
|
|
115
|
+
*
|
|
116
|
+
* @throws {BadDigestError} if the value is missing or malformed
|
|
117
|
+
*/
|
|
118
|
+
function _parseDockerContentDigest(dcd) {
|
|
119
|
+
if (!dcd)
|
|
120
|
+
throw new e.BadDigestError('missing "Docker-Content-Digest" header');
|
|
121
|
+
const errPre = `could not parse Docker-Content-Digest header "${dcd}": `;
|
|
122
|
+
// E.g. docker-content-digest: sha256:887f7ecfd0bda3...
|
|
123
|
+
const parts = splitIntoTwo(dcd, ":");
|
|
124
|
+
if (parts.length !== 2)
|
|
125
|
+
throw new e.BadDigestError(errPre + JSON.stringify(dcd));
|
|
126
|
+
if (parts[0] !== "sha256")
|
|
127
|
+
throw new e.BadDigestError(errPre + "Unsupported hash algorithm " + JSON.stringify(parts[0]));
|
|
128
|
+
return {
|
|
129
|
+
raw: dcd,
|
|
130
|
+
algorithm: parts[0],
|
|
131
|
+
expectedDigest: parts[1],
|
|
132
|
+
async validate(buffer) {
|
|
133
|
+
switch (this.algorithm) {
|
|
134
|
+
case "sha256": {
|
|
135
|
+
const hashBuffer = await getCrypto().subtle.digest("SHA-256", buffer);
|
|
136
|
+
const digest = encodeHex(hashBuffer);
|
|
137
|
+
if (this.expectedDigest !== digest) {
|
|
138
|
+
throw new e.BadDigestError(`Docker-Content-Digest mismatch (expected: ${this.expectedDigest}, got: ${digest})`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
throw new e.BadDigestError(`Unsupported hash algorithm ${this.algorithm}`);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export class OciRegistryClient {
|
|
149
|
+
version = 2;
|
|
150
|
+
insecure;
|
|
151
|
+
repo;
|
|
152
|
+
acceptOCIManifests;
|
|
153
|
+
acceptManifestLists;
|
|
154
|
+
username;
|
|
155
|
+
password;
|
|
156
|
+
scopes;
|
|
157
|
+
_loggedIn;
|
|
158
|
+
_loggedInScope;
|
|
159
|
+
_authInfo;
|
|
160
|
+
_headers;
|
|
161
|
+
_url;
|
|
162
|
+
_userAgent;
|
|
163
|
+
_adapter;
|
|
164
|
+
/**
|
|
165
|
+
* Create a new GHCR client for a particular repository.
|
|
166
|
+
*
|
|
167
|
+
* @param opts.insecure {Boolean} Optional. Default false. Set to true
|
|
168
|
+
* to *not* fail on an invalid or this-signed server certificate.
|
|
169
|
+
* @param opts.adapter {FetchAdapter} Required. HTTP adapter for making requests.
|
|
170
|
+
* ... TODO: lots more to document
|
|
171
|
+
*
|
|
172
|
+
*/
|
|
173
|
+
constructor(opts) {
|
|
174
|
+
this.insecure = Boolean(opts.insecure);
|
|
175
|
+
if (opts.repo) {
|
|
176
|
+
this.repo = opts.repo;
|
|
177
|
+
}
|
|
178
|
+
else if (opts.name) {
|
|
179
|
+
this.repo = parseRepo(opts.name);
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
throw new Error(`name or repo required`);
|
|
183
|
+
this.acceptOCIManifests = opts.acceptOCIManifests ?? true;
|
|
184
|
+
this.acceptManifestLists = opts.acceptManifestLists ?? false;
|
|
185
|
+
this.username = opts.username;
|
|
186
|
+
this.password = opts.password;
|
|
187
|
+
this.scopes = opts.scopes ?? ["pull"];
|
|
188
|
+
this._loggedIn = false;
|
|
189
|
+
this._loggedInScope = null; // Keeps track of the login type.
|
|
190
|
+
this._authInfo = null;
|
|
191
|
+
this._headers = {};
|
|
192
|
+
if (opts.token) {
|
|
193
|
+
_setAuthHeaderFromAuthInfo(this._headers, {
|
|
194
|
+
type: "Bearer",
|
|
195
|
+
token: opts.token,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else if (opts.username || opts.password) {
|
|
199
|
+
_setAuthHeaderFromAuthInfo(this._headers, {
|
|
200
|
+
type: "Basic",
|
|
201
|
+
username: opts.username ?? "",
|
|
202
|
+
password: opts.password ?? "",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
_setAuthHeaderFromAuthInfo(this._headers, {
|
|
207
|
+
type: "None",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
this._url = urlFromIndex(this.repo.index, opts.scheme);
|
|
211
|
+
this._userAgent = opts.userAgent || DEFAULT_USERAGENT;
|
|
212
|
+
this._adapter = opts.adapter;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Login V2
|
|
216
|
+
*
|
|
217
|
+
* Typically one does not need to call this function directly because most
|
|
218
|
+
* methods of a `GHCRClient` will automatically login as necessary.
|
|
219
|
+
*
|
|
220
|
+
* @param opts {Object}
|
|
221
|
+
* - opts.scope {String} Optional. A scope string passed in for
|
|
222
|
+
* bearer/token auth. If this is just a login request where the token
|
|
223
|
+
* won't be used, then the empty string (the default) is sufficient.
|
|
224
|
+
* // JSSTYLED
|
|
225
|
+
* See <https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md#requesting-a-token>
|
|
226
|
+
* @return an object with authentication info
|
|
227
|
+
*/
|
|
228
|
+
async performLogin(opts) {
|
|
229
|
+
return {
|
|
230
|
+
type: "Bearer",
|
|
231
|
+
token: await this._getToken({
|
|
232
|
+
realm: REALM,
|
|
233
|
+
service: SERVICE,
|
|
234
|
+
scopes: opts.scope ? [opts.scope] : [],
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get an auth token.
|
|
240
|
+
*
|
|
241
|
+
* See: docker/docker.git:registry/token.go
|
|
242
|
+
*/
|
|
243
|
+
async _getToken(opts) {
|
|
244
|
+
// - add https:// prefix (or http) if none on 'realm'
|
|
245
|
+
let tokenUrl = opts.realm;
|
|
246
|
+
const match = /^(\w+):\/\//.exec(tokenUrl);
|
|
247
|
+
if (!match) {
|
|
248
|
+
tokenUrl = (this.insecure ? "http" : "https") + "://" + tokenUrl;
|
|
249
|
+
}
|
|
250
|
+
else if (match[1] && ["http", "https"].indexOf(match[1]) === -1) {
|
|
251
|
+
// TODO: Verify the logic above
|
|
252
|
+
throw new Error("unsupported scheme for " +
|
|
253
|
+
`WWW-Authenticate realm "${opts.realm}": "${match[1]}"`);
|
|
254
|
+
}
|
|
255
|
+
// - GET $realm
|
|
256
|
+
// ?service=$service
|
|
257
|
+
// (&scope=$scope)*
|
|
258
|
+
// (&account=$username)
|
|
259
|
+
// Authorization: Basic ...
|
|
260
|
+
const headers = {};
|
|
261
|
+
const query = new URLSearchParams();
|
|
262
|
+
if (opts.service) {
|
|
263
|
+
query.set("service", opts.service);
|
|
264
|
+
}
|
|
265
|
+
if (opts.scopes && opts.scopes.length) {
|
|
266
|
+
for (const scope of opts.scopes) {
|
|
267
|
+
query.append("scope", scope); // intentionally singular 'scope'
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (this.username) {
|
|
271
|
+
query.set("account", this.username);
|
|
272
|
+
_setAuthHeaderFromAuthInfo(headers, {
|
|
273
|
+
type: "Basic",
|
|
274
|
+
username: this.username,
|
|
275
|
+
password: this.password ?? "",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (query.toString()) {
|
|
279
|
+
tokenUrl += "?" + query.toString();
|
|
280
|
+
}
|
|
281
|
+
// log.trace({tokenUrl: tokenUrl}, '_getToken: url');
|
|
282
|
+
headers["user-agent"] = this._userAgent;
|
|
283
|
+
const resp = await this._adapter.fetch(tokenUrl, {
|
|
284
|
+
method: "GET",
|
|
285
|
+
headers: headers,
|
|
286
|
+
});
|
|
287
|
+
if (resp.status === 401) {
|
|
288
|
+
// Convert *all* 401 errors to use a generic error constructor
|
|
289
|
+
// with a simple error message.
|
|
290
|
+
const body = await resp.json();
|
|
291
|
+
const errMsg = _getRegistryErrorMessage(body);
|
|
292
|
+
throw new Error(`Registry auth failed: ${errMsg}`);
|
|
293
|
+
}
|
|
294
|
+
if (resp.status !== 200) {
|
|
295
|
+
throw new Error(`Unexpected HTTP ${resp.status} from ${tokenUrl}`);
|
|
296
|
+
}
|
|
297
|
+
const body = (await resp.json());
|
|
298
|
+
if (typeof body?.token !== "string") {
|
|
299
|
+
console.error("TODO: auth resp:", body);
|
|
300
|
+
throw new Error("authorization " + "server did not include a token in the response");
|
|
301
|
+
}
|
|
302
|
+
return body.token;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Get a registry session (i.e. login to the registry).
|
|
306
|
+
*
|
|
307
|
+
* Typically one does not need to call this method directly because most
|
|
308
|
+
* methods of a client will automatically login as necessary.
|
|
309
|
+
*
|
|
310
|
+
* @param opts {Object} Optional.
|
|
311
|
+
* - opts.scope {String} Optional. Scope to use in the auth Bearer token.
|
|
312
|
+
*
|
|
313
|
+
* Side-effects:
|
|
314
|
+
* - On success, all of `this._loggedIn*`, `this._authInfo`, and
|
|
315
|
+
* `this._headers.authorization` are set.
|
|
316
|
+
*/
|
|
317
|
+
async login(opts = {}) {
|
|
318
|
+
const scope = opts.scope ||
|
|
319
|
+
_makeAuthScope("repository", this.repo.remoteName, this.scopes);
|
|
320
|
+
if (this._loggedIn && this._loggedInScope === scope) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const authInfo = await this.performLogin({
|
|
324
|
+
scope: scope,
|
|
325
|
+
});
|
|
326
|
+
this._loggedIn = true;
|
|
327
|
+
this._loggedInScope = scope;
|
|
328
|
+
this._authInfo = authInfo;
|
|
329
|
+
_setAuthHeaderFromAuthInfo(this._headers, authInfo);
|
|
330
|
+
// this.log.trace({err: err, loggedIn: this._loggedIn}, 'login: done');
|
|
331
|
+
}
|
|
332
|
+
async listTags(props = {}) {
|
|
333
|
+
const searchParams = new URLSearchParams();
|
|
334
|
+
if (props.pageSize != null)
|
|
335
|
+
searchParams.set("n", `${props.pageSize}`);
|
|
336
|
+
if (props.startingAfter != null)
|
|
337
|
+
searchParams.set("last", props.startingAfter);
|
|
338
|
+
await this.login();
|
|
339
|
+
const url = new URL(`/v2/${encodeURI(this.repo.remoteName)}/tags/list`, this._url);
|
|
340
|
+
url.search = searchParams.toString();
|
|
341
|
+
const headers = { ...this._headers, "user-agent": this._userAgent };
|
|
342
|
+
const resp = await this._adapter.fetch(url.toString(), {
|
|
343
|
+
method: "GET",
|
|
344
|
+
headers,
|
|
345
|
+
});
|
|
346
|
+
if (!resp.ok) {
|
|
347
|
+
throw new Error(`Unexpected HTTP ${resp.status} from ${url.toString()}`);
|
|
348
|
+
}
|
|
349
|
+
return (await resp.json());
|
|
350
|
+
}
|
|
351
|
+
async listAllTags(props = {}) {
|
|
352
|
+
const pages = [];
|
|
353
|
+
for await (const page of this.listTagsPaginated(props)) {
|
|
354
|
+
pages.push(page);
|
|
355
|
+
}
|
|
356
|
+
const firstPage = pages.shift();
|
|
357
|
+
for (const nextPage of pages) {
|
|
358
|
+
firstPage.tags = [...firstPage.tags, ...nextPage.tags];
|
|
359
|
+
}
|
|
360
|
+
return firstPage;
|
|
361
|
+
}
|
|
362
|
+
async *listTagsPaginated(props = {}) {
|
|
363
|
+
await this.login();
|
|
364
|
+
let path = `/v2/${encodeURI(this.repo.remoteName)}/tags/list`;
|
|
365
|
+
if (props.pageSize != null) {
|
|
366
|
+
path += `?n=${props.pageSize}`;
|
|
367
|
+
}
|
|
368
|
+
while (path) {
|
|
369
|
+
const url = new URL(path, this._url);
|
|
370
|
+
const headers = { ...this._headers, "user-agent": this._userAgent };
|
|
371
|
+
const resp = await this._adapter.fetch(url.toString(), {
|
|
372
|
+
method: "GET",
|
|
373
|
+
headers,
|
|
374
|
+
});
|
|
375
|
+
if (!resp.ok) {
|
|
376
|
+
throw new Error(`Unexpected HTTP ${resp.status} from ${url.toString()}`);
|
|
377
|
+
}
|
|
378
|
+
const linkHeader = resp.headers.get("link");
|
|
379
|
+
const links = parseLinkHeader(linkHeader ?? null);
|
|
380
|
+
const nextLink = links.find((x) => x.rel == "next");
|
|
381
|
+
// If there's no next link then we use a null to end the loop.
|
|
382
|
+
path = nextLink?.url ?? null;
|
|
383
|
+
yield (await resp.json());
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/*
|
|
387
|
+
* Get an image manifest. `ref` is either a tag or a digest.
|
|
388
|
+
* <https://docs.docker.com/registry/spec/api/#pulling-an-image-manifest>
|
|
389
|
+
*
|
|
390
|
+
* Note that docker-content-digest header can be undefined, so if you
|
|
391
|
+
* need a manifest digest, use the `digestFromManifestStr` function.
|
|
392
|
+
*/
|
|
393
|
+
async getManifest(opts) {
|
|
394
|
+
const acceptOCIManifests = opts.acceptOCIManifests ?? this.acceptOCIManifests;
|
|
395
|
+
const acceptManifestLists = opts.acceptManifestLists ?? this.acceptManifestLists;
|
|
396
|
+
await this.login();
|
|
397
|
+
const headers = {
|
|
398
|
+
...this._headers,
|
|
399
|
+
"user-agent": this._userAgent,
|
|
400
|
+
};
|
|
401
|
+
const acceptTypes = [MEDIATYPE_MANIFEST_V2];
|
|
402
|
+
if (acceptManifestLists) {
|
|
403
|
+
acceptTypes.push(MEDIATYPE_MANIFEST_LIST_V2);
|
|
404
|
+
}
|
|
405
|
+
if (acceptOCIManifests) {
|
|
406
|
+
acceptTypes.push(MEDIATYPE_OCI_MANIFEST_V1);
|
|
407
|
+
if (acceptManifestLists) {
|
|
408
|
+
acceptTypes.push(MEDIATYPE_OCI_MANIFEST_INDEX_V1);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
headers["accept"] = acceptTypes.join(", ");
|
|
412
|
+
const url = new URL(`/v2/${encodeURI(this.repo.remoteName ?? "")}/manifests/${encodeURI(opts.ref)}`, this._url);
|
|
413
|
+
const resp = await this._adapter.fetch(url.toString(), {
|
|
414
|
+
method: "GET",
|
|
415
|
+
headers: headers,
|
|
416
|
+
redirect: opts.followRedirects == false ? "manual" : "follow",
|
|
417
|
+
});
|
|
418
|
+
if (resp.status === 401) {
|
|
419
|
+
const body = await resp.json();
|
|
420
|
+
const errMsg = _getRegistryErrorMessage(body);
|
|
421
|
+
throw new Error(`Manifest ${JSON.stringify(opts.ref)} Not Found: ${errMsg}`);
|
|
422
|
+
}
|
|
423
|
+
if (!resp.ok) {
|
|
424
|
+
throw new Error(`Unexpected HTTP ${resp.status} from ${url.toString()}`);
|
|
425
|
+
}
|
|
426
|
+
const manifest = (await resp.json());
|
|
427
|
+
if (manifest.schemaVersion === 1) {
|
|
428
|
+
throw new Error(`schemaVersion 1 is not supported by /x/docker_registry_client.`);
|
|
429
|
+
}
|
|
430
|
+
return { resp, manifest };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Makes a http request to the given url, following any redirects, then fires
|
|
434
|
+
* the callback(err, req, responses) with the result.
|
|
435
|
+
*
|
|
436
|
+
* Note that 'responses' is an *array* of Response objects, with
|
|
437
|
+
* the last response being at the end of the array. When there is more than
|
|
438
|
+
* one response, it means a redirect has been followed.
|
|
439
|
+
*/
|
|
440
|
+
async _makeHttpRequest(opts) {
|
|
441
|
+
const followRedirects = opts.followRedirects ?? true;
|
|
442
|
+
const maxRedirects = opts.maxRedirects ?? 3;
|
|
443
|
+
let numRedirs = 0;
|
|
444
|
+
const req = {
|
|
445
|
+
path: opts.path,
|
|
446
|
+
headers: opts.headers,
|
|
447
|
+
};
|
|
448
|
+
const ress = new Array();
|
|
449
|
+
while (numRedirs < maxRedirects) {
|
|
450
|
+
numRedirs += 1;
|
|
451
|
+
const url = new URL(req.path, this._url);
|
|
452
|
+
const headers = {
|
|
453
|
+
...req.headers,
|
|
454
|
+
"user-agent": this._userAgent,
|
|
455
|
+
};
|
|
456
|
+
const resp = await this._adapter.fetch(url.toString(), {
|
|
457
|
+
method: opts.method,
|
|
458
|
+
headers: headers,
|
|
459
|
+
redirect: "manual",
|
|
460
|
+
});
|
|
461
|
+
ress.push(resp);
|
|
462
|
+
if (!followRedirects)
|
|
463
|
+
return ress;
|
|
464
|
+
if (!(resp.status === 302 || resp.status === 307))
|
|
465
|
+
return ress;
|
|
466
|
+
const location = resp.headers.get("location");
|
|
467
|
+
if (!location)
|
|
468
|
+
return ress;
|
|
469
|
+
const loc = new URL(location, url);
|
|
470
|
+
// this.log.trace({numRedirs: numRedirs, loc: loc}, 'got redir response');
|
|
471
|
+
req.path = loc.toString();
|
|
472
|
+
req.headers = {};
|
|
473
|
+
}
|
|
474
|
+
throw new e.TooManyRedirectsError(`maximum number of redirects (${maxRedirects}) hit`);
|
|
475
|
+
}
|
|
476
|
+
async _headOrGetBlob(method, digest) {
|
|
477
|
+
await this.login();
|
|
478
|
+
return await this._makeHttpRequest({
|
|
479
|
+
method: method,
|
|
480
|
+
path: `/v2/${encodeURI(this.repo.remoteName ?? "")}/blobs/${encodeURI(digest)}`,
|
|
481
|
+
headers: this._headers,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
/*
|
|
485
|
+
* Get an image file blob -- just the headers. See `getBlob`.
|
|
486
|
+
*
|
|
487
|
+
* <https://docs.docker.com/registry/spec/api/#get-blob>
|
|
488
|
+
* <https://docs.docker.com/registry/spec/api/#pulling-an-image-manifest>
|
|
489
|
+
*
|
|
490
|
+
* This endpoint can return 3xx redirects. The first response often redirects
|
|
491
|
+
* to an object CDN, which would then return the raw data.
|
|
492
|
+
*
|
|
493
|
+
* Interesting headers:
|
|
494
|
+
* - `ress[0].headers.get('docker-content-digest')` is the digest of the
|
|
495
|
+
* content to be downloaded
|
|
496
|
+
* - `ress[-1].headers.get('content-length')` is the number of bytes to download
|
|
497
|
+
* - `ress[-1].headers[*]` as appropriate for HTTP caching, range gets, etc.
|
|
498
|
+
*/
|
|
499
|
+
async headBlob(opts) {
|
|
500
|
+
const resp = await this._headOrGetBlob("HEAD", opts.digest);
|
|
501
|
+
// No need to cancel body - fetch returns complete responses
|
|
502
|
+
return resp;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Download a blob and return its ArrayBuffer.
|
|
506
|
+
* <https://docs.docker.com/registry/spec/api/#get-blob>
|
|
507
|
+
*
|
|
508
|
+
* @return
|
|
509
|
+
* The `buffer` is the blob's content as an ArrayBuffer.
|
|
510
|
+
* `ress` (plural of 'res') is an array of responses
|
|
511
|
+
* after following redirects. The full set of responses are returned mainly because
|
|
512
|
+
* headers on both the first, e.g. 'Docker-Content-Digest', and last,
|
|
513
|
+
* e.g. 'Content-Length', might be interesting.
|
|
514
|
+
*/
|
|
515
|
+
async downloadBlob(opts) {
|
|
516
|
+
const ress = await this._headOrGetBlob("GET", opts.digest);
|
|
517
|
+
const lastResp = ress[ress.length - 1];
|
|
518
|
+
if (!lastResp) {
|
|
519
|
+
throw new e.BlobReadError(`No response available for blob ${opts.digest}`);
|
|
520
|
+
}
|
|
521
|
+
const buffer = await lastResp.arrayBuffer();
|
|
522
|
+
const dcdHeader = ress[0]?.headers.get("docker-content-digest");
|
|
523
|
+
if (dcdHeader) {
|
|
524
|
+
const dcdInfo = _parseDockerContentDigest(dcdHeader);
|
|
525
|
+
if (dcdInfo.raw !== opts.digest) {
|
|
526
|
+
throw new e.BadDigestError(`Docker-Content-Digest header, ${dcdInfo.raw}, does not match ` +
|
|
527
|
+
`given digest, ${opts.digest}`);
|
|
528
|
+
}
|
|
529
|
+
// Validate the digest
|
|
530
|
+
await dcdInfo.validate(buffer);
|
|
531
|
+
}
|
|
532
|
+
return { ress, buffer };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Upload a blob using POST then PUT workflow.
|
|
536
|
+
* <https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put>
|
|
537
|
+
*
|
|
538
|
+
* @param opts.data The blob data as ArrayBuffer or Uint8Array
|
|
539
|
+
* @param opts.digest Optional digest. If not provided, it will be calculated.
|
|
540
|
+
* @returns Object with digest and size of the uploaded blob
|
|
541
|
+
*/
|
|
542
|
+
async pushBlob(opts) {
|
|
543
|
+
await this.login();
|
|
544
|
+
// Convert to ArrayBuffer if needed
|
|
545
|
+
const buffer = opts.data instanceof Uint8Array
|
|
546
|
+
? new Uint8Array(opts.data).buffer
|
|
547
|
+
: opts.data;
|
|
548
|
+
// Calculate digest
|
|
549
|
+
const hashBuffer = await getCrypto().subtle.digest("SHA-256", buffer);
|
|
550
|
+
const digest = `sha256:${encodeHex(hashBuffer)}`;
|
|
551
|
+
// Step 1: POST to initiate upload
|
|
552
|
+
const postUrl = new URL(`/v2/${encodeURI(this.repo.remoteName)}/blobs/uploads/`, this._url);
|
|
553
|
+
const postHeaders = {
|
|
554
|
+
...this._headers,
|
|
555
|
+
"user-agent": this._userAgent,
|
|
556
|
+
"content-length": "0",
|
|
557
|
+
};
|
|
558
|
+
const postResp = await this._adapter.fetch(postUrl.toString(), {
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: postHeaders,
|
|
561
|
+
});
|
|
562
|
+
if (postResp.status !== 202) {
|
|
563
|
+
throw new Error(`Failed to initiate blob upload: HTTP ${postResp.status}`);
|
|
564
|
+
}
|
|
565
|
+
// Get upload URL from Location header
|
|
566
|
+
const uploadLocation = postResp.headers.get("location");
|
|
567
|
+
if (!uploadLocation) {
|
|
568
|
+
throw new Error("No Location header in POST response");
|
|
569
|
+
}
|
|
570
|
+
// Step 2: PUT to upload blob
|
|
571
|
+
const uploadUrl = new URL(uploadLocation, this._url);
|
|
572
|
+
uploadUrl.searchParams.set("digest", digest);
|
|
573
|
+
const putHeaders = {
|
|
574
|
+
...this._headers,
|
|
575
|
+
"user-agent": this._userAgent,
|
|
576
|
+
"content-type": "application/octet-stream",
|
|
577
|
+
"content-length": buffer.byteLength.toString(),
|
|
578
|
+
};
|
|
579
|
+
const putResp = await this._adapter.fetch(uploadUrl.toString(), {
|
|
580
|
+
method: "PUT",
|
|
581
|
+
headers: putHeaders,
|
|
582
|
+
body: buffer,
|
|
583
|
+
});
|
|
584
|
+
if (putResp.status !== 201) {
|
|
585
|
+
throw new Error(`Failed to upload blob: HTTP ${putResp.status}`);
|
|
586
|
+
}
|
|
587
|
+
// Verify digest from response
|
|
588
|
+
const returnedDigest = putResp.headers.get("docker-content-digest");
|
|
589
|
+
if (returnedDigest && returnedDigest !== digest) {
|
|
590
|
+
throw new e.BadDigestError(`Digest mismatch: expected ${digest}, got ${returnedDigest}`);
|
|
591
|
+
}
|
|
592
|
+
return { digest, size: buffer.byteLength };
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Upload an image manifest.
|
|
596
|
+
* <https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests>
|
|
597
|
+
*
|
|
598
|
+
* @param opts.ref The tag or digest to push to
|
|
599
|
+
* @param opts.manifest The manifest object to upload
|
|
600
|
+
* @param opts.mediaType Optional media type (defaults to OCI manifest type)
|
|
601
|
+
* @returns Object with digest and size of the uploaded manifest
|
|
602
|
+
*/
|
|
603
|
+
async pushManifest(opts) {
|
|
604
|
+
await this.login();
|
|
605
|
+
const manifestStr = JSON.stringify(opts.manifest);
|
|
606
|
+
const manifestBuffer = new TextEncoder().encode(manifestStr);
|
|
607
|
+
// Calculate digest
|
|
608
|
+
const digest = await digestFromManifestStr(manifestStr);
|
|
609
|
+
const url = new URL(`/v2/${encodeURI(this.repo.remoteName)}/manifests/${encodeURI(opts.ref)}`, this._url);
|
|
610
|
+
const headers = {
|
|
611
|
+
...this._headers,
|
|
612
|
+
"user-agent": this._userAgent,
|
|
613
|
+
"content-type": opts.mediaType || "application/vnd.oci.image.manifest.v1+json",
|
|
614
|
+
"content-length": manifestBuffer.byteLength.toString(),
|
|
615
|
+
};
|
|
616
|
+
const resp = await this._adapter.fetch(url.toString(), {
|
|
617
|
+
method: "PUT",
|
|
618
|
+
headers,
|
|
619
|
+
body: manifestBuffer,
|
|
620
|
+
});
|
|
621
|
+
if (resp.status !== 201) {
|
|
622
|
+
throw new Error(`Failed to push manifest: HTTP ${resp.status}`);
|
|
623
|
+
}
|
|
624
|
+
// Verify digest from response
|
|
625
|
+
const returnedDigest = resp.headers.get("docker-content-digest");
|
|
626
|
+
if (returnedDigest && returnedDigest !== digest) {
|
|
627
|
+
throw new e.BadDigestError(`Digest mismatch: expected ${digest}, got ${returnedDigest}`);
|
|
628
|
+
}
|
|
629
|
+
return { digest, size: manifestBuffer.byteLength };
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Push a plugin manifest as a config blob and create an OCI manifest.
|
|
633
|
+
* This follows the OCI spec where the Obsidian manifest is stored as the config.
|
|
634
|
+
*
|
|
635
|
+
* @param opts.ref The tag or digest to push to
|
|
636
|
+
* @param opts.pluginManifest The Obsidian plugin manifest
|
|
637
|
+
* @param opts.layers The layer descriptors (main.js, styles.css, etc.)
|
|
638
|
+
* @param opts.annotations Optional annotations for the OCI manifest
|
|
639
|
+
* @returns Object with digest, configDigest, and the created manifest
|
|
640
|
+
*/
|
|
641
|
+
async pushPluginManifest(opts) {
|
|
642
|
+
// Step 1: Push plugin manifest as config blob
|
|
643
|
+
const manifestStr = JSON.stringify(opts.pluginManifest);
|
|
644
|
+
const manifestBuffer = new TextEncoder().encode(manifestStr);
|
|
645
|
+
const configResult = await this.pushBlob({
|
|
646
|
+
data: manifestBuffer,
|
|
647
|
+
});
|
|
648
|
+
// Step 2: Build OCI manifest with plugin manifest as config
|
|
649
|
+
const manifest = {
|
|
650
|
+
schemaVersion: 2,
|
|
651
|
+
mediaType: MEDIATYPE_OCI_MANIFEST_V1,
|
|
652
|
+
artifactType: "application/vnd.obsidian.plugin.v1+json",
|
|
653
|
+
config: {
|
|
654
|
+
mediaType: MEDIATYPE_OBSIDIAN_PLUGIN_CONFIG_V1,
|
|
655
|
+
digest: configResult.digest,
|
|
656
|
+
size: configResult.size,
|
|
657
|
+
},
|
|
658
|
+
layers: opts.layers,
|
|
659
|
+
annotations: opts.annotations,
|
|
660
|
+
};
|
|
661
|
+
// Step 3: Push the OCI manifest
|
|
662
|
+
const manifestResult = await this.pushManifest({
|
|
663
|
+
ref: opts.ref,
|
|
664
|
+
manifest,
|
|
665
|
+
mediaType: MEDIATYPE_OCI_MANIFEST_V1,
|
|
666
|
+
});
|
|
667
|
+
return {
|
|
668
|
+
digest: manifestResult.digest,
|
|
669
|
+
configDigest: configResult.digest,
|
|
670
|
+
manifest,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Pull a plugin manifest by extracting it from the OCI config blob.
|
|
675
|
+
* This follows the OCI spec where the Obsidian manifest is stored as the config.
|
|
676
|
+
*
|
|
677
|
+
* @param opts.ref The tag or digest to pull
|
|
678
|
+
* @returns Object with the plugin manifest, OCI manifest, and digests
|
|
679
|
+
*/
|
|
680
|
+
async pullPluginManifest(opts) {
|
|
681
|
+
// Step 1: Pull the OCI manifest
|
|
682
|
+
const manifestResult = await this.getManifest({ ref: opts.ref });
|
|
683
|
+
const manifest = manifestResult.manifest;
|
|
684
|
+
// Step 2: Validate manifest has config
|
|
685
|
+
if (!("config" in manifest) || !manifest.config) {
|
|
686
|
+
throw new Error("Manifest does not contain a config");
|
|
687
|
+
}
|
|
688
|
+
const ociManifest = manifest;
|
|
689
|
+
const manifestDigest = manifestResult.resp.headers.get("docker-content-digest") || "";
|
|
690
|
+
// Step 3: Pull the config blob
|
|
691
|
+
const { buffer: configBuffer } = await this.downloadBlob({
|
|
692
|
+
digest: ociManifest.config.digest,
|
|
693
|
+
});
|
|
694
|
+
// Step 4: Parse the plugin manifest from config
|
|
695
|
+
const configText = new TextDecoder().decode(configBuffer);
|
|
696
|
+
const pluginManifest = JSON.parse(configText);
|
|
697
|
+
return {
|
|
698
|
+
pluginManifest,
|
|
699
|
+
manifest: ociManifest,
|
|
700
|
+
manifestDigest,
|
|
701
|
+
configDigest: ociManifest.config.digest,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|