2018-09-26 22:27:42 +08:00
|
|
|
import parseLink from 'parse-link-header';
|
|
|
|
|
2018-09-29 15:39:45 +08:00
|
|
|
import { registryAPI, deleteEnabled, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options';
|
2018-09-26 22:27:42 +08:00
|
|
|
|
2018-09-27 13:41:08 +08:00
|
|
|
function parseWWWAuthenticate(text) {
|
|
|
|
const result = {};
|
|
|
|
// extract auth-scheme
|
|
|
|
const schemeParts = text.split(' ');
|
|
|
|
[result.scheme] = schemeParts;
|
|
|
|
// extract auth-params
|
|
|
|
const remain = schemeParts.slice(1).join(' ');
|
|
|
|
const parts = remain.split(/ ?, ?/);
|
|
|
|
parts.forEach((part) => {
|
|
|
|
// parse auth-param
|
|
|
|
const kv = part.split('=');
|
|
|
|
if (kv.length === 2) {
|
|
|
|
const key = kv[0].trim();
|
|
|
|
const value = kv[1].trim();
|
|
|
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
|
|
result[key] = value.substring(1, value.length - 1);
|
|
|
|
} else {
|
|
|
|
result[key] = value;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
result[parts] = true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2018-09-27 14:10:11 +08:00
|
|
|
const cachedTokens = {};
|
|
|
|
async function doAuth(scope) {
|
2018-09-27 14:32:15 +08:00
|
|
|
if (cachedTokens[scope] !== undefined) {
|
2018-09-27 14:10:11 +08:00
|
|
|
return cachedTokens[scope];
|
2018-09-27 13:41:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// try accessing registry API
|
|
|
|
const response = await fetch(`${await registryAPI()}/v2/`);
|
|
|
|
if (response.ok) {
|
|
|
|
// token not needed for registry
|
2018-09-27 14:10:11 +08:00
|
|
|
cachedTokens[scope] = false;
|
|
|
|
return cachedTokens[scope];
|
2018-09-27 13:41:08 +08:00
|
|
|
}
|
|
|
|
if (response.status !== 401) {
|
|
|
|
throw new Error(response.statusText);
|
|
|
|
}
|
|
|
|
|
|
|
|
// solve authentication challenge
|
|
|
|
const { headers } = response;
|
|
|
|
if (!headers.has('Www-Authenticate')) {
|
|
|
|
throw new Error('no challenge presented');
|
|
|
|
}
|
|
|
|
const chal = parseWWWAuthenticate(headers.get('Www-Authenticate'));
|
|
|
|
if (chal.scheme !== 'Bearer') {
|
|
|
|
throw new Error('unsupported scheme');
|
|
|
|
}
|
|
|
|
|
|
|
|
const tokURL = new URL(chal.realm);
|
|
|
|
tokURL.searchParams.append('client_id', 'dri-client');
|
|
|
|
tokURL.searchParams.append('service', chal.service);
|
2018-09-27 14:10:11 +08:00
|
|
|
tokURL.searchParams.append('scope', scope);
|
2018-09-27 13:41:08 +08:00
|
|
|
const tokResponse = await fetch(tokURL);
|
|
|
|
const tok = await tokResponse.json();
|
2018-09-27 14:10:11 +08:00
|
|
|
cachedTokens[scope] = tok.token;
|
2018-09-27 13:41:08 +08:00
|
|
|
return tok.token;
|
|
|
|
}
|
|
|
|
|
2018-09-27 14:10:11 +08:00
|
|
|
async function paginatable(path, scope, n, last = null) {
|
2018-09-27 13:41:08 +08:00
|
|
|
const url = new URL(`${await registryAPI()}${path}`);
|
|
|
|
if (n) url.searchParams.append('n', n);
|
|
|
|
if (last) url.searchParams.append('last', last);
|
|
|
|
|
|
|
|
const headers = {};
|
2018-09-27 14:10:11 +08:00
|
|
|
if (scope) {
|
|
|
|
const token = await doAuth(scope);
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
}
|
2018-09-27 13:41:08 +08:00
|
|
|
const response = await fetch(url, { headers });
|
|
|
|
let nextLast = null;
|
|
|
|
if (response.headers.has('Link')) {
|
|
|
|
const links = parseLink(response.headers.get('Link'));
|
|
|
|
if (links && links.next) {
|
|
|
|
nextLast = links.next.last;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return Object.assign(await response.json(), { nextLast });
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
2018-09-29 15:39:45 +08:00
|
|
|
async function request(method, path, scope, accept) {
|
2018-09-27 13:41:08 +08:00
|
|
|
const url = new URL(`${await registryAPI()}${path}`);
|
|
|
|
const headers = {};
|
2018-09-29 15:39:45 +08:00
|
|
|
if (accept) {
|
|
|
|
headers.Accept = accept;
|
2018-09-27 14:10:11 +08:00
|
|
|
}
|
|
|
|
if (scope) {
|
|
|
|
const token = await doAuth(scope);
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
}
|
2018-09-29 15:39:45 +08:00
|
|
|
const response = await fetch(url, { method, headers });
|
|
|
|
if (!response.ok) {
|
|
|
|
if (method === 'HEAD') {
|
|
|
|
throw new Error(`${response.statusText}`);
|
|
|
|
} else if (response.headers.get('Content-Type').startsWith('application/json')) {
|
|
|
|
const r = await response.json();
|
|
|
|
const firstError = r.errors[0];
|
|
|
|
if (firstError) {
|
|
|
|
throw new Error(`${firstError.code}: ${firstError.message}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw new Error(`${response.statusText}`);
|
|
|
|
}
|
|
|
|
if (!response.headers.get('Content-Type').startsWith('application/json')) {
|
|
|
|
console.warn('response returned was not JSON, parsing may fail');
|
|
|
|
}
|
|
|
|
if (method === 'HEAD' || parseInt(response.headers.get('Content-Length'), 10) < 1) {
|
|
|
|
return { headers: response.headers };
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
...(await response.json()),
|
|
|
|
headers: response.headers,
|
|
|
|
};
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
2018-09-27 13:41:08 +08:00
|
|
|
async function portus() {
|
|
|
|
// TODO: Use the Portus API when it enables anonymous access
|
|
|
|
const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`);
|
|
|
|
const html = await response.text();
|
|
|
|
|
|
|
|
// unconventionally parse out JSON from HTML
|
|
|
|
const startString = 'window.repositories = ';
|
|
|
|
const endString = ';</script>';
|
|
|
|
const string = html.substring(
|
|
|
|
html.lastIndexOf(startString) + startString.length,
|
|
|
|
html.indexOf(endString),
|
|
|
|
);
|
|
|
|
const object = JSON.parse(string);
|
|
|
|
return object;
|
|
|
|
}
|
|
|
|
|
2018-09-26 22:27:42 +08:00
|
|
|
async function repos(last = null) {
|
2018-09-27 13:41:08 +08:00
|
|
|
if (await usePortusExplore()) {
|
|
|
|
const p = await portus();
|
|
|
|
return {
|
|
|
|
repositories: p.map(r => r.full_name),
|
|
|
|
};
|
|
|
|
}
|
2018-09-27 14:10:11 +08:00
|
|
|
return paginatable('/v2/_catalog', null, await repositoriesPerPage(), last);
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
async function tags(name, last = null) {
|
2018-09-27 13:41:08 +08:00
|
|
|
if (await usePortusExplore()) {
|
|
|
|
const p = await portus();
|
|
|
|
return {
|
|
|
|
tags: p.find(r => r.full_name === name)
|
|
|
|
.tags.map(t => t[0].name),
|
|
|
|
};
|
|
|
|
}
|
2018-09-27 14:10:11 +08:00
|
|
|
return paginatable(`/v2/${name}/tags/list`, `repository:${name}:pull`, await tagsPerPage(), last);
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
async function tag(name, ref) {
|
2018-09-29 15:39:45 +08:00
|
|
|
return request('GET', `/v2/${name}/manifests/${ref}`, `repository:${name}:pull`, 'application/vnd.docker.distribution.manifest.v2+json');
|
|
|
|
}
|
|
|
|
|
|
|
|
async function tagCanDelete(name, ref) {
|
|
|
|
if (!await deleteEnabled()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
const { headers } = await request('HEAD', `/v2/${name}/manifests/${ref}`, `repository:${name}:delete`, 'application/vnd.docker.distribution.manifest.v2+json');
|
|
|
|
request('HEAD', `/v2/${name}/manifests/${headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`);
|
|
|
|
return true;
|
|
|
|
} catch (e) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function tagDelete(name, ref) {
|
|
|
|
const tagManifest = await tag(name, ref);
|
|
|
|
// delete each blob
|
|
|
|
// await Promise.all(tagManifest.layers.map(l =>
|
|
|
|
// request('DELETE', `/v2/${name}/blobs/${l.digest}`, `repository:${name}:delete`)));
|
|
|
|
return request('DELETE', `/v2/${name}/manifests/${tagManifest.headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function repoCanDelete(name) {
|
|
|
|
if (!await deleteEnabled()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`);
|
|
|
|
if (!r.tags) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return Promise.race(r.tags.map(t => tagCanDelete(name, t)));
|
|
|
|
}
|
|
|
|
|
|
|
|
async function repoDelete(name) {
|
|
|
|
const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`);
|
|
|
|
return Promise.all(r.tags.map(t => tagDelete(name, t)));
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
async function blob(name, digest) {
|
2018-09-29 15:39:45 +08:00
|
|
|
const { headers } = await request('HEAD', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
|
2018-09-27 13:41:08 +08:00
|
|
|
return {
|
2018-09-29 15:39:45 +08:00
|
|
|
dockerContentDigest: headers.get('Docker-Content-Digest'),
|
2018-09-27 13:41:08 +08:00
|
|
|
contentLength: parseInt(headers.get('Content-Length'), 10),
|
|
|
|
};
|
2018-09-26 22:27:42 +08:00
|
|
|
}
|
|
|
|
|
2018-09-29 15:39:45 +08:00
|
|
|
async function configBlob(name, digest) {
|
|
|
|
return request('GET', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
|
|
|
|
}
|
|
|
|
|
2018-09-26 22:27:42 +08:00
|
|
|
export {
|
2018-09-27 13:41:08 +08:00
|
|
|
repos,
|
|
|
|
tags,
|
|
|
|
tag,
|
2018-09-29 15:39:45 +08:00
|
|
|
tagCanDelete,
|
|
|
|
tagDelete,
|
|
|
|
repoCanDelete,
|
|
|
|
repoDelete,
|
2018-09-27 13:41:08 +08:00
|
|
|
blob,
|
2018-09-29 15:39:45 +08:00
|
|
|
configBlob,
|
2018-09-26 22:27:42 +08:00
|
|
|
};
|