관리-도구
편집 파일: index.js
const { URL } = require('node:url') const timers = require('node:timers/promises') const fetch = require('npm-registry-fetch') const { HttpErrorBase } = require('npm-registry-fetch/lib/errors') const { log } = require('proc-log') // try loginWeb, catch the "not supported" message and fall back to couch const login = async (opener, prompter, opts = {}) => { try { return await loginWeb(opener, opts) } catch (er) { if (er instanceof WebLoginNotSupported) { log.verbose('web login', 'not supported, trying couch') const { username, password } = await prompter(opts.creds) return loginCouch(username, password, opts) } throw er } } const adduser = async (opener, prompter, opts = {}) => { try { return await adduserWeb(opener, opts) } catch (er) { if (er instanceof WebLoginNotSupported) { log.verbose('web adduser', 'not supported, trying couch') const { username, email, password } = await prompter(opts.creds) return adduserCouch(username, email, password, opts) } throw er } } const adduserWeb = (opener, opts = {}) => { log.verbose('web adduser', 'before first POST') return webAuth(opener, opts, { create: true }) } const loginWeb = (opener, opts = {}) => { log.verbose('web login', 'before first POST') return webAuth(opener, opts, {}) } const isValidUrl = u => { try { return /^https?:$/.test(new URL(u).protocol) } catch { return false } } const webAuth = async (opener, opts, body) => { try { const res = await fetch('/-/v1/login', { ...opts, method: 'POST', body, }) const content = await res.json() log.verbose('web auth', 'got response', content) const { doneUrl, loginUrl } = content if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) { throw new WebLoginInvalidResponse('POST', res, content) } return await webAuthOpener(opener, loginUrl, doneUrl, opts) } catch (er) { if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) { throw new WebLoginNotSupported('POST', { status: er.statusCode, headers: er.headers, }, er.body) } throw er } } const webAuthOpener = async (opener, loginUrl, doneUrl, opts) => { const abortController = new AbortController() const { signal } = abortController try { log.verbose('web auth', 'opening url pair') const [, authResult] = await Promise.all([ opener(loginUrl, { signal }).catch((err) => { if (err.name === 'AbortError') { abortController.abort() return } throw err }), webAuthCheckLogin(doneUrl, { ...opts, cache: false }, { signal }).then((r) => { log.verbose('web auth', 'done-check finished') abortController.abort() return r }), ]) return authResult } catch (er) { abortController.abort() throw er } } const webAuthCheckLogin = async (doneUrl, opts, { signal } = {}) => { signal?.throwIfAborted() const res = await fetch(doneUrl, opts) const content = await res.json() if (res.status === 200) { if (!content.token) { throw new WebLoginInvalidResponse('GET', res, content) } return content } if (res.status === 202) { const retry = +res.headers.get('retry-after') * 1000 if (retry > 0) { await timers.setTimeout(retry, null, { ref: false, signal }) } return webAuthCheckLogin(doneUrl, opts, { signal }) } throw new WebLoginInvalidResponse('GET', res, content) } const couchEndpoint = (username) => `/-/user/org.couchdb.user:${encodeURIComponent(username)}` const putCouch = async (path, username, body, opts) => { const result = await fetch.json(`${couchEndpoint(username)}${path}`, { ...opts, method: 'PUT', body, }) result.username = username return result } const adduserCouch = async (username, email, password, opts = {}) => { const body = { _id: `org.couchdb.user:${username}`, name: username, password: password, email: email, type: 'user', roles: [], date: new Date().toISOString(), } log.verbose('adduser', 'before first PUT', { ...body, password: 'XXXXX', }) return putCouch('', username, body, opts) } const loginCouch = async (username, password, opts = {}) => { const body = { _id: `org.couchdb.user:${username}`, name: username, password: password, type: 'user', roles: [], date: new Date().toISOString(), } log.verbose('login', 'before first PUT', { ...body, password: 'XXXXX', }) try { return await putCouch('', username, body, opts) } catch (err) { if (err.code === 'E400') { err.message = `There is no user with the username "${username}".` throw err } if (err.code !== 'E409') { throw err } } const result = await fetch.json(couchEndpoint(username), { ...opts, query: { write: true }, }) for (const k of Object.keys(result)) { if (!body[k] || k === 'roles') { body[k] = result[k] } } return putCouch(`/-rev/${body._rev}`, username, body, { ...opts, forceAuth: { username, password: Buffer.from(password, 'utf8').toString('base64'), otp: opts.otp, }, }) } const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts) const set = (profile, opts = {}) => fetch.json('/-/npm/v1/user', { ...opts, method: 'POST', // profile keys can't be empty strings, but they CAN be null body: Object.fromEntries(Object.entries(profile).map(([k, v]) => [k, v === '' ? null : v])), }) const paginate = async (href, opts, items = []) => { const result = await fetch.json(href, opts) items = items.concat(result.objects) if (result.urls.next) { return paginate(result.urls.next, opts, items) } return items } const listTokens = (opts = {}) => paginate('/-/npm/v1/tokens', opts) const removeToken = async (tokenKey, opts = {}) => { await fetch(`/-/npm/v1/tokens/token/${tokenKey}`, { ...opts, method: 'DELETE', ignoreBody: true, }) return null } const createToken = (password, readonly, cidrs, opts = {}) => fetch.json('/-/npm/v1/tokens', { ...opts, method: 'POST', body: { password: password, readonly: readonly, cidr_whitelist: cidrs, }, }) class WebLoginInvalidResponse extends HttpErrorBase { constructor (method, res, body) { super(method, res, body) this.message = 'Invalid response from web login endpoint' } } class WebLoginNotSupported extends HttpErrorBase { constructor (method, res, body) { super(method, res, body) this.message = 'Web login not supported' this.code = 'ENYI' } } module.exports = { adduserCouch, loginCouch, adduserWeb, loginWeb, login, adduser, get, set, listTokens, removeToken, createToken, webAuthCheckLogin, webAuthOpener, }