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) , isUpload = what || this.conf.get('always-auth') , isDel = method === "DELETE" , authRequired = isUpload && !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 && !this.conf.get('always-auth')) { var couch = this.couchLogin , token = couch && (this.conf.get('_token') || couch.token) , validToken = token && couch.valid(token) if (!validToken) token = null else this.conf.set('_token', token) if (couch && !token) { // login to get a valid token var a = { name: this.conf.get('username'), password: this.conf.get('_password') } var args = arguments return this.couchLogin.login(a, function (er, cr, data) { if (er || !couch.valid(couch.token)) { er = er || new Error('login error') return cb(er, cr, data) } this.conf.set('_token', this.couchLogin.token) return regRequest.call(this, method, where, what, etag, nofollow, cb_) }.bind(this)) } } // now we either have a valid token, or an auth. if (authRequired && !auth && !token) { return cb(new Error( "Cannot insert data into the registry without auth")) } if (auth && !token) { remote.auth = new Buffer(auth, "base64").toString("utf8") } // Tuned to spread 3 attempts over about a minute. // See formula at . 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 var reauthed = false; 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, token , 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 reauth = !reauthed && ( statusCode === 401 || statusCode === 400 || statusCode === 403 ) var timeout = statusCode === 408 var serverError = statusCode >= 500 var statusRetry = !statusCode || timeout || serverError if (reauth && this.conf.get('_auth') && this.conf.get('_token')) { this.conf.del('_token') this.couchLogin.token = null return regRequest.call(this, method, where, what, etag, nofollow, cb_) } 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, tok, 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') , strictSSL: strict } , headers = opts.headers = {} if (etag) { this.log.verbose("etag", etag) headers[method === "GET" ? "if-none-match" : "if-match"] = etag } if (tok) { headers.cookie = 'AuthSession=' + tok.AuthSession } 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) this.log.http(response.statusCode, url.parse(where).href) 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) 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) { 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")) caches.push(path.join(this.conf.get('cache'), p.join("/"))) } asyncMap(caches, rm, function () {}) } return cb(er, parsed, data, response) }.bind(this) }