summary refs log tree commit diff
path: root/node-mastodon/lib/mastodon.js
diff options
context:
space:
mode:
Diffstat (limited to 'node-mastodon/lib/mastodon.js')
-rw-r--r--node-mastodon/lib/mastodon.js340
1 files changed, 340 insertions, 0 deletions
diff --git a/node-mastodon/lib/mastodon.js b/node-mastodon/lib/mastodon.js
new file mode 100644
index 0000000..7f5c8c1
--- /dev/null
+++ b/node-mastodon/lib/mastodon.js
@@ -0,0 +1,340 @@
+//
+//  Mastodon API Wrapper
+//
+var assert = require('assert');
+var Promise = require('bluebird');
+var request = require('request');
+var util = require('util');
+var helpers = require('./helpers');
+var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON;
+
+var DEFAULT_REST_ROOT = 'https://mastodon.social/api/v1/';
+
+var required_for_user_auth = [
+  'access_token',
+];
+
+//
+//  Mastodon
+//
+var Mastodon = function (config) {
+  if (!(this instanceof Mastodon)) {
+    return new Mastodon(config);
+  }
+
+  var self = this
+  var credentials = {
+    access_token : config.access_token
+  }
+
+  this.apiUrl = config.api_url || DEFAULT_REST_ROOT;
+
+  this._validateConfigOrThrow(config);
+  this.config = config;
+  this._mastodon_time_minus_local_time_ms = 0;
+}
+
+Mastodon.prototype.get = function (path, params, callback) {
+  return this.request('GET', path, params, callback)
+}
+
+Mastodon.prototype.post = function (path, params, callback) {
+  return this.request('POST', path, params, callback)
+}
+
+Mastodon.prototype.patch = function (path, params, callback) {
+  return this.request('PATCH', path, params, callback)
+}
+
+Mastodon.prototype.delete = function (path, params, callback) {
+  return this.request('DELETE', path, params, callback)
+}
+
+Mastodon.prototype.request = function (method, path, params, callback) {
+  var self = this;
+  assert(method == 'GET' || method == 'POST' || method == 'PATCH' || method == 'DELETE');
+  // if no `params` is specified but a callback is, use default params
+  if (typeof params === 'function') {
+    callback = params
+    params = {}
+  }
+
+  return new Promise(function (resolve, reject) {
+    var _returnErrorToUser = function (err) {
+      if (callback && typeof callback === 'function') {
+        callback(err, null, null);
+      }
+      reject(err);
+    }
+
+    self._buildReqOpts(method, path, params, function (err, reqOpts) {
+      if (err) {
+        _returnErrorToUser(err);
+        return
+      }
+
+      var mastoOptions = (params && params.masto_options) || {};
+
+      process.nextTick(function () {
+        // ensure all HTTP i/o occurs after the user has a chance to bind their event handlers
+        self._doRestApiRequest(reqOpts, mastoOptions, method, function (err, parsedBody, resp) {
+          if (err) {
+            _returnErrorToUser(err);
+            return
+          }
+          self._updateClockOffsetFromResponse(resp);
+
+          if (self.config.trusted_cert_fingerprints) {
+            if (!resp.socket.authorized) {
+              // The peer certificate was not signed by one of the authorized CA's.
+              var authErrMsg = resp.socket.authorizationError.toString();
+              var err = helpers.makeMastodonError('The peer certificate was not signed; ' + authErrMsg);
+              _returnErrorToUser(err);
+              return;
+            }
+            var fingerprint = resp.socket.getPeerCertificate().fingerprint;
+            var trustedFingerprints = self.config.trusted_cert_fingerprints;
+            if (trustedFingerprints.indexOf(fingerprint) === -1) {
+              var errMsg = util.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.',
+                                       trustedFingerprints.join(','), fingerprint);
+              var err = new Error(errMsg);
+              _returnErrorToUser(err);
+              return;
+            }
+          }
+
+          if (callback && typeof callback === 'function') {
+            callback(err, parsedBody, resp);
+          }
+
+          resolve({ data: parsedBody, resp: resp });
+          return;
+        })
+      })
+    });
+  });
+}
+
+Mastodon.prototype._updateClockOffsetFromResponse = function (resp) {
+  var self = this;
+  if (resp && resp.headers && resp.headers.date &&
+      new Date(resp.headers.date).toString() !== 'Invalid Date'
+  ) {
+    var mastodonTimeMs = new Date(resp.headers.date).getTime()
+    self._mastodon_time_minus_local_time_ms = mastodonTimeMs - Date.now();
+  }
+}
+
+/**
+ * Builds and returns an options object ready to pass to `request()`
+ * @param  {String}   method      "GET", "POST", or "DELETE"
+ * @param  {String}   path        REST API resource uri (eg. "statuses/destroy/:id")
+ * @param  {Object}   params      user's params object
+ * @returns {Undefined}
+ *
+ * Calls `callback` with Error, Object where Object is an options object ready to pass to `request()`.
+ *
+ * Returns error raised (if any) by `helpers.moveParamsIntoPath()`
+ */
+Mastodon.prototype._buildReqOpts = function (method, path, params, callback) {
+  var self = this
+  if (!params) {
+    params = {}
+  }
+  var finalParams = params;
+  delete finalParams.masto_options
+
+  // the options object passed to `request` used to perform the HTTP request
+  var reqOpts = {
+    headers: {
+      'Accept': '*/*',
+      'User-Agent': 'node-mastodon-client',
+      'Authorization': 'Bearer ' + self.config.access_token
+    },
+    gzip: true,
+    encoding: null,
+    rejectUnauthorized: false,
+    insecure: true,
+  }
+
+  if (typeof self.config.timeout_ms !== 'undefined') {
+    reqOpts.timeout = self.config.timeout_ms;
+  }
+
+  try {
+    // finalize the `path` value by building it using user-supplied params
+    path = helpers.moveParamsIntoPath(finalParams, path)
+  } catch (e) {
+    callback(e, null, null)
+    return
+  }
+
+  if (path.match(/^https?:\/\//i)) {
+    // This is a full url request
+    reqOpts.url = path
+  } else {
+    // This is a REST API request.
+    reqOpts.url = this.apiUrl + path;
+  }
+
+  if (finalParams.file) {
+    // If we're sending a file
+    reqOpts.headers['Content-type'] = 'multipart/form-data';
+    reqOpts.formData = finalParams;
+  } else {
+    // Non-file-upload params should be url-encoded
+    if (Object.keys(finalParams).length > 0) {
+      reqOpts.url += this.formEncodeParams(finalParams);
+    }
+  }
+
+  callback(null, reqOpts);
+  return;
+}
+
+/**
+ * Make HTTP request to Mastodon REST API.
+ * @param  {Object}   reqOpts     options object passed to `request()`
+ * @param  {Object}   mastoOptions
+ * @param  {String}   method      "GET", "POST", or "DELETE"
+ * @param  {Function} callback    user's callback
+ * @return {Undefined}
+ */
+Mastodon.prototype._doRestApiRequest = function (reqOpts, mastoOptions, method, callback) {
+  var request_method = request[method.toLowerCase()];
+  var req = request_method(reqOpts);
+
+  var body = '';
+  var response = null;
+
+  var onRequestComplete = function () {
+    if (body !== '') {
+      try {
+        body = JSON.parse(body)
+      } catch (jsonDecodeError) {
+        // there was no transport-level error, but a JSON object could not be decoded from the request body
+        // surface this to the caller
+        var err = helpers.makeMastodonError('JSON decode error: Mastodon HTTP response body was not valid JSON')
+        err.statusCode = response ? response.statusCode: null;
+        err.allErrors.concat({error: jsonDecodeError.toString()})
+        callback(err, body, response);
+        return
+      }
+    }
+
+    if (typeof body === 'object' && (body.error || body.errors)) {
+      // we got a Mastodon API-level error response
+      // place the errors in the HTTP response body into the Error object and pass control to caller
+      var err = helpers.makeMastodonError('Mastodon API Error')
+      err.statusCode = response ? response.statusCode: null;
+      helpers.attachBodyInfoToError(err, body);
+      callback(err, body, response);
+      return
+    }
+
+    // success case - no errors in HTTP response body
+    callback(err, body, response)
+  }
+
+  req.on('response', function (res) {
+    response = res
+    // read data from `request` object which contains the decompressed HTTP response body,
+    // `response` is the unmodified http.IncomingMessage object which may contain compressed data
+    req.on('data', function (chunk) {
+      body += chunk.toString('utf8')
+    })
+    // we're done reading the response
+    req.on('end', function () {
+      onRequestComplete()
+    })
+  })
+
+  req.on('error', function (err) {
+    // transport-level error occurred - likely a socket error
+    if (mastoOptions.retry &&
+        STATUS_CODES_TO_ABORT_ON.indexOf(err.statusCode) !== -1
+    ) {
+      // retry the request since retries were specified and we got a status code we should retry on
+      self.request(method, path, params, callback);
+      return;
+    } else {
+      // pass the transport-level error to the caller
+      err.statusCode = null
+      err.code = null
+      err.allErrors = [];
+      helpers.attachBodyInfoToError(err, body)
+      callback(err, body, response);
+      return;
+    }
+  })
+}
+
+Mastodon.prototype.formEncodeParams = function (params, noQuestionMark) {
+  var encoded = '';
+  for (var key in params) {
+    var value = params[key];
+    if (encoded === '') {
+      if (!noQuestionMark) {
+        encoded = '?';
+      }
+    } else {
+      encoded += '&';
+    }
+
+    if (Array.isArray(value)) {
+      value.forEach(function(v) {
+        encoded += encodeURIComponent(key) + '[]=' + encodeURIComponent(v) + '&';
+      });
+    } else {
+      encoded += encodeURIComponent(key) + '=' + encodeURIComponent(value);
+    }
+  }
+
+  return (encoded);
+}
+
+Mastodon.prototype.setAuth = function (auth) {
+  var self = this
+  var configKeys = [
+    'access_token'
+  ];
+
+  // update config
+  configKeys.forEach(function (k) {
+    if (auth[k]) {
+      self.config[k] = auth[k]
+    }
+  })
+  this._validateConfigOrThrow(self.config);
+}
+
+Mastodon.prototype.getAuth = function () {
+  return this.config
+}
+
+//
+// Check that the required auth credentials are present in `config`.
+// @param {Object}  config  Object containing credentials for REST API auth
+//
+Mastodon.prototype._validateConfigOrThrow = function (config) {
+  //check config for proper format
+  if (typeof config !== 'object') {
+    throw new TypeError('config must be object, got ' + typeof config)
+  }
+
+  if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) {
+    throw new TypeError('Mastodon config `timeout_ms` must be a Number. Got: ' + config.timeout_ms + '.');
+  }
+
+  var auth_type = 'user auth'
+  var required_keys = required_for_user_auth
+
+  required_keys.forEach(function (req_key) {
+    if (!config[req_key]) {
+      var err_msg = util.format('Mastodon config must include `%s` when using %s.', req_key, auth_type)
+      throw new Error(err_msg)
+    }
+  })
+}
+
+module.exports = Mastodon