|
|
|
module.exports = regRequest
|
|
|
|
|
|
|
|
var url = require("url")
|
|
|
|
, fs = require("graceful-fs")
|
|
|
|
, rm = require("rimraf")
|
|
|
|
, asyncMap = require("slide").asyncMap
|
|
|
|
, Stream = require("stream").Stream
|
|
|
|
, request = require("request")
|
|
|
|
, retry = require("retry")
|
|
|
|
|
|
|
|
function regRequest (method, where, what, etag, nofollow, cb_) {
|
|
|
|
if (typeof cb_ !== "function") cb_ = nofollow, nofollow = false
|
|
|
|
if (typeof cb_ !== "function") cb_ = etag, etag = null
|
|
|
|
if (typeof cb_ !== "function") cb_ = what, what = null
|
|
|
|
|
|
|
|
var registry = this.conf.get('registry')
|
|
|
|
if (!registry) return cb(new Error(
|
|
|
|
"No registry url provided: " + method + " " + where))
|
|
|
|
|
|
|
|
// Since there are multiple places where an error could occur,
|
|
|
|
// don't let the cb be called more than once.
|
|
|
|
var errState = null
|
|
|
|
function cb (er) {
|
|
|
|
if (errState) return
|
|
|
|
if (er) errState = er
|
|
|
|
cb_.apply(null, arguments)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (where.match(/^\/?favicon.ico/)) {
|
|
|
|
return cb(new Error("favicon.ico isn't a package, it's a picture."))
|
|
|
|
}
|
|
|
|
|
|
|
|
var adduserChange = /^\/?-\/user\/org\.couchdb\.user:([^\/]+)\/-rev/
|
|
|
|
, adduserNew = /^\/?-\/user\/org\.couchdb\.user:([^\/]+)/
|
|
|
|
, nu = where.match(adduserNew)
|
|
|
|
, uc = where.match(adduserChange)
|
|
|
|
, alwaysAuth = this.conf.get('always-auth')
|
|
|
|
, isDel = method === "DELETE"
|
|
|
|
, isWrite = what || isDel
|
|
|
|
, authRequired = (alwaysAuth || isWrite) && !nu || uc || isDel
|
|
|
|
|
|
|
|
// resolve to a full url on the registry
|
|
|
|
if (!where.match(/^https?:\/\//)) {
|
|
|
|
this.log.verbose("url raw", where)
|
|
|
|
|
|
|
|
var q = where.split("?")
|
|
|
|
where = q.shift()
|
|
|
|
q = q.join("?")
|
|
|
|
|
|
|
|
if (where.charAt(0) !== "/") where = "/" + where
|
|
|
|
where = "." + where.split("/").map(function (p) {
|
|
|
|
p = p.trim()
|
|
|
|
if (p.match(/^org.couchdb.user/)) {
|
|
|
|
return p.replace(/\//g, encodeURIComponent("/"))
|
|
|
|
}
|
|
|
|
return encodeURIComponent(p)
|
|
|
|
}).join("/")
|
|
|
|
if (q) where += "?" + q
|
|
|
|
this.log.verbose("url resolving", [registry, where])
|
|
|
|
where = url.resolve(registry, where)
|
|
|
|
this.log.verbose("url resolved", where)
|
|
|
|
}
|
|
|
|
|
|
|
|
var remote = url.parse(where)
|
|
|
|
, auth = this.conf.get('_auth')
|
|
|
|
|
|
|
|
if (authRequired && !auth) {
|
|
|
|
var un = this.conf.get('username')
|
|
|
|
var pw = this.conf.get('_password')
|
|
|
|
if (un && pw)
|
|
|
|
auth = new Buffer(un + ':' + pw).toString('base64')
|
|
|
|
}
|
|
|
|
|
|
|
|
if (authRequired && !auth) {
|
|
|
|
return cb(new Error(
|
|
|
|
"Cannot insert data into the registry without auth"))
|
|
|
|
}
|
|
|
|
|
|
|
|
if (auth && authRequired) {
|
|
|
|
remote.auth = new Buffer(auth, "base64").toString("utf8")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Tuned to spread 3 attempts over about a minute.
|
|
|
|
// See formula at <https://github.com/tim-kos/node-retry>.
|
|
|
|
var operation = retry.operation({
|
|
|
|
retries: this.conf.get('fetch-retries') || 2,
|
|
|
|
factor: this.conf.get('fetch-retry-factor'),
|
|
|
|
minTimeout: this.conf.get('fetch-retry-mintimeout') || 10000,
|
|
|
|
maxTimeout: this.conf.get('fetch-retry-maxtimeout') || 60000
|
|
|
|
})
|
|
|
|
|
|
|
|
var self = this
|
|
|
|
operation.attempt(function (currentAttempt) {
|
|
|
|
self.log.info("trying", "registry request attempt " + currentAttempt
|
|
|
|
+ " at " + (new Date()).toLocaleTimeString())
|
|
|
|
makeRequest.call(self, method, remote, where, what, etag, nofollow
|
|
|
|
, function (er, parsed, raw, response) {
|
|
|
|
if (!er || er.message.match(/^SSL Error/)) {
|
|
|
|
if (er)
|
|
|
|
er.code = 'ESSL'
|
|
|
|
return cb(er, parsed, raw, response)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only retry on 408, 5xx or no `response`.
|
|
|
|
var statusCode = response && response.statusCode
|
|
|
|
|
|
|
|
var timeout = statusCode === 408
|
|
|
|
var serverError = statusCode >= 500
|
|
|
|
var statusRetry = !statusCode || timeout || serverError
|
|
|
|
if (er && statusRetry && operation.retry(er)) {
|
|
|
|
self.log.info("retry", "will retry, error on last attempt: " + er)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
cb.apply(null, arguments)
|
|
|
|
}.bind(this))
|
|
|
|
}.bind(this))
|
|
|
|
}
|
|
|
|
|
|
|
|
function makeRequest (method, remote, where, what, etag, nofollow, cb_) {
|
|
|
|
var cbCalled = false
|
|
|
|
function cb () {
|
|
|
|
if (cbCalled) return
|
|
|
|
cbCalled = true
|
|
|
|
cb_.apply(null, arguments)
|
|
|
|
}
|
|
|
|
|
|
|
|
var strict = this.conf.get('strict-ssl')
|
|
|
|
if (strict === undefined) strict = true
|
|
|
|
var opts = { url: remote
|
|
|
|
, method: method
|
|
|
|
, ca: this.conf.get('ca')
|
|
|
|
, localAddress: this.conf.get('local-address')
|
|
|
|
, cert: this.conf.get('cert')
|
|
|
|
, key: this.conf.get('key')
|
|
|
|
, strictSSL: strict }
|
|
|
|
, headers = opts.headers = {}
|
|
|
|
if (etag) {
|
|
|
|
this.log.verbose("etag", etag)
|
|
|
|
headers[method === "GET" ? "if-none-match" : "if-match"] = etag
|
|
|
|
}
|
|
|
|
|
|
|
|
headers.accept = "application/json"
|
|
|
|
|
|
|
|
headers["user-agent"] = this.conf.get('user-agent') ||
|
|
|
|
'node/' + process.version
|
|
|
|
|
|
|
|
var p = this.conf.get('proxy')
|
|
|
|
var sp = this.conf.get('https-proxy') || p
|
|
|
|
opts.proxy = remote.protocol === "https:" ? sp : p
|
|
|
|
|
|
|
|
// figure out wth 'what' is
|
|
|
|
if (what) {
|
|
|
|
if (Buffer.isBuffer(what) || typeof what === "string") {
|
|
|
|
opts.body = what
|
|
|
|
headers["content-type"] = "application/json"
|
|
|
|
headers["content-length"] = Buffer.byteLength(what)
|
|
|
|
} else if (what instanceof Stream) {
|
|
|
|
headers["content-type"] = "application/octet-stream"
|
|
|
|
if (what.size) headers["content-length"] = what.size
|
|
|
|
} else {
|
|
|
|
delete what._etag
|
|
|
|
opts.json = what
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (nofollow) {
|
|
|
|
opts.followRedirect = false
|
|
|
|
}
|
|
|
|
|
|
|
|
this.log.http(method, remote.href || "/")
|
|
|
|
|
|
|
|
var done = requestDone.call(this, method, where, cb)
|
|
|
|
var req = request(opts, done)
|
|
|
|
|
|
|
|
req.on("error", cb)
|
|
|
|
req.on("socket", function (s) {
|
|
|
|
s.on("error", cb)
|
|
|
|
})
|
|
|
|
|
|
|
|
if (what && (what instanceof Stream)) {
|
|
|
|
what.pipe(req)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// cb(er, parsed, raw, response)
|
|
|
|
function requestDone (method, where, cb) {
|
|
|
|
return function (er, response, data) {
|
|
|
|
if (er) return cb(er)
|
|
|
|
|
|
|
|
var urlObj = url.parse(where)
|
|
|
|
if (urlObj.auth)
|
|
|
|
urlObj.auth = '***'
|
|
|
|
this.log.http(response.statusCode, url.format(urlObj))
|
|
|
|
|
|
|
|
var parsed
|
|
|
|
|
|
|
|
if (Buffer.isBuffer(data)) {
|
|
|
|
data = data.toString()
|
|
|
|
}
|
|
|
|
|
|
|
|
if (data && typeof data === "string" && response.statusCode !== 304) {
|
|
|
|
try {
|
|
|
|
parsed = JSON.parse(data)
|
|
|
|
} catch (ex) {
|
|
|
|
ex.message += "\n" + data
|
|
|
|
this.log.verbose("bad json", data)
|
|
|
|
this.log.error("registry", "error parsing json")
|
|
|
|
return cb(ex, null, data, response)
|
|
|
|
}
|
|
|
|
} else if (data) {
|
|
|
|
parsed = data
|
|
|
|
data = JSON.stringify(parsed)
|
|
|
|
}
|
|
|
|
|
|
|
|
// expect data with any error codes
|
|
|
|
if (!data && response.statusCode >= 400) {
|
|
|
|
return cb( response.statusCode + " "
|
|
|
|
+ require("http").STATUS_CODES[response.statusCode]
|
|
|
|
, null, data, response )
|
|
|
|
}
|
|
|
|
|
|
|
|
var er = null
|
|
|
|
if (parsed && response.headers.etag) {
|
|
|
|
parsed._etag = response.headers.etag
|
|
|
|
}
|
|
|
|
|
|
|
|
if (parsed && parsed.error && response.statusCode >= 400) {
|
|
|
|
var w = url.parse(where).pathname.substr(1)
|
|
|
|
var name
|
|
|
|
if (!w.match(/^-/) && parsed.error === "not_found") {
|
|
|
|
w = w.split("/")
|
|
|
|
name = w[w.indexOf("_rewrite") + 1]
|
|
|
|
er = new Error("404 Not Found: "+name)
|
|
|
|
er.code = "E404"
|
|
|
|
er.pkgid = name
|
|
|
|
} else {
|
|
|
|
er = new Error(
|
|
|
|
parsed.error + " " + (parsed.reason || "") + ": " + w)
|
|
|
|
}
|
|
|
|
} else if (method !== "HEAD" && method !== "GET") {
|
|
|
|
// invalidate cache
|
|
|
|
// This is irrelevant for commands that do etag caching, but
|
|
|
|
// ls and view also have a timed cache, so this keeps the user
|
|
|
|
// from thinking that it didn't work when it did.
|
|
|
|
// Note that failure is an acceptable option here, since the
|
|
|
|
// only result will be a stale cache for some helper commands.
|
|
|
|
var path = require("path")
|
|
|
|
, p = url.parse(where).pathname.split("/")
|
|
|
|
, _ = "/"
|
|
|
|
, caches = p.map(function (part) {
|
|
|
|
part = part.replace(/:/g, "_")
|
|
|
|
return _ = path.join(_, part)
|
|
|
|
}).map(function (cache) {
|
|
|
|
return path.join(this.conf.get('cache'), cache, ".cache.json")
|
|
|
|
}, this)
|
|
|
|
|
|
|
|
// if the method is DELETE, then also remove the thing itself.
|
|
|
|
// Note that the search index is probably invalid. Whatever.
|
|
|
|
// That's what you get for deleting stuff. Don't do that.
|
|
|
|
if (method === "DELETE") {
|
|
|
|
p = p.slice(0, p.indexOf("-rev"))
|
|
|
|
p = p.join("/").replace(/:/g, "_")
|
|
|
|
caches.push(path.join(this.conf.get('cache'), p))
|
|
|
|
}
|
|
|
|
|
|
|
|
asyncMap(caches, rm, function () {})
|
|
|
|
}
|
|
|
|
return cb(er, parsed, data, response)
|
|
|
|
}.bind(this)
|
|
|
|
}
|