www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

server.js (19211B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2011 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 Zotero.Server = new function() {
     27 	var _onlineObserverRegistered, serv;
     28 	this.responseCodes = {
     29 		200:"OK",
     30 		201:"Created",
     31 		204:"No Content",
     32 		300:"Multiple Choices",
     33 		400:"Bad Request",
     34 		404:"Not Found",
     35 		409:"Conflict",
     36 		412:"Precondition Failed",
     37 		500:"Internal Server Error",
     38 		501:"Not Implemented",
     39 		503:"Service Unavailable",
     40 		504:"Gateway Timeout"
     41 	};
     42 	
     43 	/**
     44 	 * initializes a very rudimentary web server
     45 	 */
     46 	this.init = function(port, bindAllAddr, maxConcurrentConnections) {
     47 		if (Zotero.HTTP.browserIsOffline()) {
     48 			Zotero.debug('Browser is offline -- not initializing HTTP server');
     49 			_registerOnlineObserver();
     50 			return;
     51 		}
     52 		
     53 		if(serv) {
     54 			Zotero.debug("Already listening on port " + serv.port);
     55 			return;
     56 		}
     57 		
     58 		// start listening on socket
     59 		serv = Components.classes["@mozilla.org/network/server-socket;1"]
     60 					.createInstance(Components.interfaces.nsIServerSocket);
     61 		try {
     62 			// bind to a random port on loopback only
     63 			serv.init(port ? port : Zotero.Prefs.get('httpServer.port'), !bindAllAddr, -1);
     64 			serv.asyncListen(Zotero.Server.SocketListener);
     65 			
     66 			Zotero.debug("HTTP server listening on "+(bindAllAddr ? "*": " 127.0.0.1")+":"+serv.port);
     67 			
     68 			// Close port on Zotero shutdown (doesn't apply to translation-server)
     69 			if (Zotero.addShutdownListener) {
     70 				Zotero.addShutdownListener(this.close.bind(this));
     71 			}
     72 		} catch(e) {
     73 			Zotero.logError(e);
     74 			Zotero.debug("Not initializing HTTP server");
     75 			serv = undefined;
     76 		}
     77 		
     78 		_registerOnlineObserver()
     79 	}
     80 	
     81 	/**
     82 	 * releases bound port
     83 	 */
     84 	this.close = function() {
     85 		if(!serv) return;
     86 		serv.close();
     87 		serv = undefined;
     88 	};
     89 	
     90 	/**
     91 	 * Parses a query string into a key => value object
     92 	 * @param {String} queryString Query string
     93 	 */
     94 	this.decodeQueryString = function(queryString) {
     95 		var splitData = queryString.split("&");
     96 		var decodedData = {};
     97 		for (let variable of splitData) {
     98 			var splitIndex = variable.indexOf("=");
     99 			decodedData[decodeURIComponent(variable.substr(0, splitIndex))] = decodeURIComponent(variable.substr(splitIndex+1));
    100 		}
    101 		return decodedData;
    102 	}
    103 	
    104 	function _registerOnlineObserver() {
    105 		if (_onlineObserverRegistered) {
    106 			return;
    107 		}
    108 		
    109 		// Observer to enable the integration when we go online
    110 		var observer = {
    111 			observe: function(subject, topic, data) {
    112 				if (data == 'online') {
    113 					Zotero.Server.init();
    114 				}
    115 			}
    116 		};
    117 		
    118 		var observerService =
    119 			Components.classes["@mozilla.org/observer-service;1"]
    120 				.getService(Components.interfaces.nsIObserverService);
    121 		observerService.addObserver(observer, "network:offline-status-changed", false);
    122 		
    123 		_onlineObserverRegistered = true;
    124 	}
    125 }
    126 
    127 Zotero.Server.SocketListener = new function() {
    128 	this.onSocketAccepted = onSocketAccepted;
    129 	this.onStopListening = onStopListening;
    130 	
    131 	/*
    132 	 * called when a socket is opened
    133 	 */
    134 	function onSocketAccepted(socket, transport) {
    135 		// get an input stream
    136 		var iStream = transport.openInputStream(0, 0, 0);
    137 		var oStream = transport.openOutputStream(Components.interfaces.nsITransport.OPEN_BLOCKING, 0, 0);
    138 		
    139 		var dataListener = new Zotero.Server.DataListener(iStream, oStream);
    140 		var pump = Components.classes["@mozilla.org/network/input-stream-pump;1"]
    141 							 .createInstance(Components.interfaces.nsIInputStreamPump);
    142 		pump.init(iStream, -1, -1, 0, 0, false);
    143 		pump.asyncRead(dataListener, null);
    144 	}
    145 	
    146 	function onStopListening(serverSocket, status) {
    147 		Zotero.debug("HTTP server going offline");
    148 	}
    149 }
    150 
    151 /*
    152  * handles the actual acquisition of data
    153  */
    154 Zotero.Server.DataListener = function(iStream, oStream) {
    155 	this.header = "";
    156 	this.headerFinished = false;
    157 	
    158 	this.body = "";
    159 	this.bodyLength = 0;
    160 	
    161 	this.iStream = iStream;
    162 	this.oStream = oStream;
    163 	this.sStream = Components.classes["@mozilla.org/scriptableinputstream;1"]
    164 	                         .createInstance(Components.interfaces.nsIScriptableInputStream);
    165 	this.sStream.init(iStream);
    166 	
    167 	this.foundReturn = false;
    168 }
    169 
    170 /*
    171  * called when a request begins (although the request should have begun before
    172  * the DataListener was generated)
    173  */
    174 Zotero.Server.DataListener.prototype.onStartRequest = function(request, context) {}
    175 
    176 /*
    177  * called when a request stops
    178  */
    179 Zotero.Server.DataListener.prototype.onStopRequest = function(request, context, status) {
    180 	this.iStream.close();
    181 	this.oStream.close();
    182 }
    183 
    184 /*
    185  * called when new data is available
    186  */
    187 Zotero.Server.DataListener.prototype.onDataAvailable = function(request, context,
    188                                                              inputStream, offset, count) {
    189 	var readData = this.sStream.read(count);
    190 	
    191 	if(this.headerFinished) {	// reading body
    192 		this.body += readData;
    193 		// check to see if data is done
    194 		this._bodyData();
    195 	} else {					// reading header
    196 		// see if there's a magic double return
    197 		var lineBreakIndex = readData.indexOf("\r\n\r\n");
    198 		if(lineBreakIndex != -1) {
    199 			if(lineBreakIndex != 0) {
    200 				this.header += readData.substr(0, lineBreakIndex+4);
    201 				this.body = readData.substr(lineBreakIndex+4);
    202 			}
    203 			
    204 			this._headerFinished();
    205 			return;
    206 		}
    207 		var lineBreakIndex = readData.indexOf("\n\n");
    208 		if(lineBreakIndex != -1) {
    209 			if(lineBreakIndex != 0) {
    210 				this.header += readData.substr(0, lineBreakIndex+2);
    211 				this.body = readData.substr(lineBreakIndex+2);
    212 			}
    213 			
    214 			this._headerFinished();
    215 			return;
    216 		}
    217 		if(this.header && this.header[this.header.length-1] == "\n" &&
    218 		   (readData[0] == "\n" || readData[0] == "\r")) {
    219 			if(readData.length > 1 && readData[1] == "\n") {
    220 				this.header += readData.substr(0, 2);
    221 				this.body = readData.substr(2);
    222 			} else {
    223 				this.header += readData[0];
    224 				this.body = readData.substr(1);
    225 			}
    226 			
    227 			this._headerFinished();
    228 			return;
    229 		}
    230 		this.header += readData;
    231 	}
    232 }
    233 
    234 /*
    235  * processes an HTTP header and decides what to do
    236  */
    237 Zotero.Server.DataListener.prototype._headerFinished = function() {
    238 	this.headerFinished = true;
    239 	
    240 	Zotero.debug(this.header, 5);
    241 	
    242 	const methodRe = /^([A-Z]+) ([^ \r\n?]+)(\?[^ \r\n]+)?/;
    243 	const hostRe = /[\r\n]Host: *(localhost|127\.0\.0\.1)(:[0-9]+)?[\r\n]/i;
    244 	const contentTypeRe = /[\r\n]Content-Type: *([^ \r\n]+)/i;
    245 	
    246 	const originRe = /[\r\n]Origin: *([^ \r\n]+)/i;
    247 	var m = originRe.exec(this.header);
    248 	if (m) {
    249 		this.origin = m[1];
    250 	}
    251 	else {
    252 		const bookmarkletRe = /[\r\n]Zotero-Bookmarklet: *([^ \r\n]+)/i;
    253 		var m = bookmarkletRe.exec(this.header);
    254 		if (m) this.origin = "https://www.zotero.org";
    255 	}
    256 	
    257 	if (!Zotero.isServer) {
    258 		// Make sure the Host header is set to localhost/127.0.0.1 to prevent DNS rebinding attacks
    259 		if (!hostRe.exec(this.header)) {
    260 			this._requestFinished(this._generateResponse(400, "text/plain", "Invalid Host header\n"));
    261 			return;
    262 		}
    263 	}
    264 	
    265 	// get first line of request
    266 	var method = methodRe.exec(this.header);
    267 	// get content-type
    268 	var contentType = contentTypeRe.exec(this.header);
    269 	if(contentType) {
    270 		var splitContentType = contentType[1].split(/\s*;/);
    271 		this.contentType = splitContentType[0];
    272 	}
    273 	
    274 	if(!method) {
    275 		this._requestFinished(this._generateResponse(400, "text/plain", "Invalid method specified\n"));
    276 		return;
    277 	}
    278 	if(!Zotero.Server.Endpoints[method[2]]) {
    279 		this._requestFinished(this._generateResponse(404, "text/plain", "No endpoint found\n"));
    280 		return;
    281 	}
    282 	this.pathname = method[2];
    283 	this.endpoint = Zotero.Server.Endpoints[method[2]];
    284 	this.query = method[3];
    285 	
    286 	if(method[1] == "HEAD" || method[1] == "OPTIONS") {
    287 		this._requestFinished(this._generateResponse(200));
    288 	} else if(method[1] == "GET") {
    289 		this._processEndpoint("GET", null); // async
    290 	} else if(method[1] == "POST") {
    291 		const contentLengthRe = /[\r\n]Content-Length: +([0-9]+)/i;
    292 		
    293 		// parse content length
    294 		var m = contentLengthRe.exec(this.header);
    295 		if(!m) {
    296 			this._requestFinished(this._generateResponse(400, "text/plain", "Content-length not provided\n"));
    297 			return;
    298 		}
    299 		
    300 		this.bodyLength = parseInt(m[1]);
    301 		this._bodyData();
    302 	} else {
    303 		this._requestFinished(this._generateResponse(501, "text/plain", "Method not implemented\n"));
    304 		return;
    305 	}
    306 }
    307 
    308 /*
    309  * checks to see if Content-Length bytes of body have been read and, if so, processes the body
    310  */
    311 Zotero.Server.DataListener.prototype._bodyData = function() {
    312 	if(this.body.length >= this.bodyLength) {
    313 		// convert to UTF-8
    314 		var dataStream = Components.classes["@mozilla.org/io/string-input-stream;1"]
    315 		                           .createInstance(Components.interfaces.nsIStringInputStream);
    316 		dataStream.setData(this.body, this.bodyLength);
    317 		
    318 		var utf8Stream = Components.classes["@mozilla.org/intl/converter-input-stream;1"]
    319 		                           .createInstance(Components.interfaces.nsIConverterInputStream);
    320 		utf8Stream.init(dataStream, "UTF-8", 4096, "?");
    321 		
    322 		this.body = "";
    323 		var string = {};
    324 		while(utf8Stream.readString(this.bodyLength, string)) {
    325 			this.body += string.value;
    326 		}		
    327 		
    328 		// handle envelope
    329 		this._processEndpoint("POST", this.body); // async
    330 	}
    331 }
    332 	
    333 /**
    334  * Generates the response to an HTTP request
    335  */
    336 Zotero.Server.DataListener.prototype._generateResponse = function (status, contentTypeOrHeaders, body) {
    337 	var response = "HTTP/1.0 "+status+" "+Zotero.Server.responseCodes[status]+"\r\n";
    338 	
    339 	// Translation server
    340 	if (Zotero.isServer) {
    341 		// Add CORS headers if Origin header matches the allowed origins
    342 		if (this.origin) {
    343 			let allowedOrigins = Zotero.Prefs.get('httpServer.allowedOrigins')
    344 				.split(/, */).filter(x => x);
    345 			let allAllowed = allowedOrigins.includes('*');
    346 			if (allAllowed || allowedOrigins.includes(this.origin)) {
    347 				response += "Access-Control-Allow-Origin: " + (allAllowed ? '*' : this.origin) + "\r\n";
    348 				response += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n";
    349 				response += "Access-Control-Allow-Headers: Content-Type\r\n";
    350 				response += "Access-Control-Expose-Headers: Link\r\n";
    351 			}
    352 		}
    353 	}
    354 	// Client
    355 	else {
    356 		response += "X-Zotero-Version: "+Zotero.version+"\r\n";
    357 		response += "X-Zotero-Connector-API-Version: "+CONNECTOR_API_VERSION+"\r\n";
    358 		
    359 		if (this.origin === ZOTERO_CONFIG.BOOKMARKLET_ORIGIN ||
    360 				this.origin === ZOTERO_CONFIG.HTTP_BOOKMARKLET_ORIGIN) {
    361 			response += "Access-Control-Allow-Origin: " + this.origin + "\r\n";
    362 			response += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n";
    363 			response += "Access-Control-Allow-Headers: Content-Type,X-Zotero-Connector-API-Version,X-Zotero-Version\r\n";
    364 		}
    365 	}
    366 	
    367 	if (contentTypeOrHeaders) {
    368 		if (typeof contentTypeOrHeaders == 'string') {
    369 			contentTypeOrHeaders = {
    370 				'Content-Type': contentTypeOrHeaders
    371 			};
    372 		}
    373 		for (let header in contentTypeOrHeaders) {
    374 			response += `${header}: ${contentTypeOrHeaders[header]}\r\n`;
    375 		}
    376 	}
    377 	
    378 	if(body) {
    379 		response += "\r\n"+body;
    380 	} else {
    381 		response += "Content-Length: 0\r\n\r\n";
    382 	}
    383 	
    384 	return response;
    385 }
    386 
    387 /**
    388  * Generates a response based on calling the function associated with the endpoint
    389  */
    390 Zotero.Server.DataListener.prototype._processEndpoint = Zotero.Promise.coroutine(function* (method, postData) {
    391 	try {
    392 		var endpoint = new this.endpoint;
    393 		
    394 		// Check that endpoint supports method
    395 		if(endpoint.supportedMethods && endpoint.supportedMethods.indexOf(method) === -1) {
    396 			this._requestFinished(this._generateResponse(400, "text/plain", "Endpoint does not support method\n"));
    397 			return;
    398 		}
    399 		
    400 		// Check that endpoint supports bookmarklet
    401 		if(this.origin) {
    402 			var isBookmarklet = this.origin === "https://www.zotero.org" || this.origin === "http://www.zotero.org";
    403 			// Disallow bookmarklet origins to access endpoints without permitBookmarklet
    404 			// set. We allow other origins to access these endpoints because they have to 
    405 			// be privileged to avoid being blocked by our headers.
    406 			if(isBookmarklet && !endpoint.permitBookmarklet) {
    407 				this._requestFinished(this._generateResponse(403, "text/plain", "Access forbidden to bookmarklet\n"));
    408 				return;
    409 			}
    410 		}
    411 		
    412 		var decodedData = null;
    413 		if(postData && this.contentType) {
    414 			// check that endpoint supports contentType
    415 			var supportedDataTypes = endpoint.supportedDataTypes;
    416 			if(supportedDataTypes && supportedDataTypes != '*' 
    417 				&& supportedDataTypes.indexOf(this.contentType) === -1) {
    418 				
    419 				this._requestFinished(this._generateResponse(400, "text/plain", "Endpoint does not support content-type\n"));
    420 				return;
    421 			}
    422 			
    423 			// decode content-type post data
    424 			if(this.contentType === "application/json") {
    425 				try {
    426 					decodedData = JSON.parse(postData);
    427 				} catch(e) {
    428 					this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n"));
    429 					return;
    430 				}
    431 			} else if(this.contentType === "application/x-www-form-urlencoded") {				
    432 				decodedData = Zotero.Server.decodeQueryString(postData);
    433 			} else if(this.contentType === "multipart/form-data") {
    434 				let boundary = /boundary=([^\s]*)/i.exec(this.header);
    435 				if (!boundary) {
    436 					return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n"));
    437 				}
    438 				boundary = '--' + boundary[1];
    439 				try {
    440 					decodedData = this._decodeMultipartData(postData, boundary);
    441 				} catch(e) {
    442 					return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n"));
    443 				}
    444 			} else {
    445 				decodedData = postData;
    446 			}
    447 		}
    448 		
    449 		// set up response callback
    450 		var sendResponseCallback = function (code, contentTypeOrHeaders, arg, options) {
    451 			this._requestFinished(
    452 				this._generateResponse(code, contentTypeOrHeaders, arg),
    453 				options
    454 			);
    455 		}.bind(this);
    456 		
    457 		// Pass to endpoint
    458 		//
    459 		// Single-parameter endpoint
    460 		//   - Takes an object with 'method', 'pathname', 'query', 'headers', and 'data'
    461 		//   - Returns a status code, an array containing [statusCode, contentType, body],
    462 		//     or a promise for either
    463 		if (endpoint.init.length === 1
    464 				// Return value from Zotero.Promise.coroutine()
    465 				|| endpoint.init.length === 0) {
    466 			let headers = {};
    467 			let headerLines = this.header.trim().split(/\r\n/);
    468 			for (let line of headerLines) {
    469 				line = line.trim();
    470 				let pos = line.indexOf(':');
    471 				if (pos == -1) {
    472 					continue;
    473 				}
    474 				let k = line.substr(0, pos);
    475 				let v = line.substr(pos + 1).trim();
    476 				headers[k] = v;
    477 			}
    478 			
    479 			let maybePromise = endpoint.init({
    480 				method,
    481 				pathname: this.pathname,
    482 				query: this.query ? Zotero.Server.decodeQueryString(this.query.substr(1)) : {},
    483 				headers,
    484 				data: decodedData
    485 			});
    486 			let result;
    487 			if (maybePromise.then) {
    488 				result = yield maybePromise;
    489 			}
    490 			else {
    491 				result = maybePromise;
    492 			}
    493 			if (Number.isInteger(result)) {
    494 				sendResponseCallback(result);
    495 			}
    496 			else {
    497 				sendResponseCallback(...result);
    498 			}
    499 		}
    500 		// Two-parameter endpoint takes data and a callback
    501 		else if (endpoint.init.length === 2) {
    502 			endpoint.init(decodedData, sendResponseCallback);
    503 		}
    504 		// Three-parameter endpoint takes a URL, data, and a callback
    505 		else {
    506 			const uaRe = /[\r\n]User-Agent: +([^\r\n]+)/i;
    507 			var m = uaRe.exec(this.header);
    508 			var url = {
    509 				"pathname":this.pathname,
    510 				"query":this.query ? Zotero.Server.decodeQueryString(this.query.substr(1)) : {},
    511 				"userAgent":m && m[1]
    512 			};
    513 			endpoint.init(url, decodedData, sendResponseCallback);
    514 		}
    515 	} catch(e) {
    516 		Zotero.debug(e);
    517 		this._requestFinished(this._generateResponse(500), "text/plain", "An error occurred\n");
    518 		throw e;
    519 	}
    520 });
    521 
    522 /*
    523  * returns HTTP data from a request
    524  */
    525 Zotero.Server.DataListener.prototype._requestFinished = function (response, options) {
    526 	if(this._responseSent) {
    527 		Zotero.debug("Request already finished; not sending another response");
    528 		return;
    529 	}
    530 	this._responseSent = true;
    531 	
    532 	// close input stream
    533 	this.iStream.close();
    534 	
    535 	// open UTF-8 converter for output stream	
    536 	var intlStream = Components.classes["@mozilla.org/intl/converter-output-stream;1"]
    537 							   .createInstance(Components.interfaces.nsIConverterOutputStream);
    538 	
    539 	// write
    540 	try {
    541 		intlStream.init(this.oStream, "UTF-8", 1024, "?".charCodeAt(0));
    542 		
    543 		// Filter logged response
    544 		if (Zotero.Debug.enabled) {
    545 			let maxLogLength = 2000;
    546 			let str = response;
    547 			if (options && options.logFilter) {
    548 				str = options.logFilter(str);
    549 			}
    550 			if (str.length > maxLogLength) {
    551 				str = str.substr(0, maxLogLength) + `\u2026 (${response.length} chars)`;
    552 			}
    553 			Zotero.debug(str, 5);
    554 		}
    555 		
    556 		intlStream.writeString(response);
    557 	} finally {	
    558 		intlStream.close();
    559 	}
    560 }
    561 
    562 Zotero.Server.DataListener.prototype._decodeMultipartData = function(data, boundary) {
    563 	var contentDispositionRe = /^Content-Disposition:\s*(.*)$/i;
    564 	var results = [];
    565 	data = data.split(boundary);
    566 	// Ignore pre first boundary and post last boundary
    567 	data = data.slice(1, data.length-1);
    568 	for (let field of data) {
    569 		let fieldData = {};
    570 		field = field.trim();
    571 		// Split header and body
    572 		let unixHeaderBoundary = field.indexOf("\n\n");
    573 		let windowsHeaderBoundary = field.indexOf("\r\n\r\n");
    574 		if (unixHeaderBoundary < windowsHeaderBoundary && unixHeaderBoundary != -1) {
    575 			fieldData.header = field.slice(0, unixHeaderBoundary);
    576 			fieldData.body = field.slice(unixHeaderBoundary+2);
    577 		} else if (windowsHeaderBoundary != -1) {
    578 			fieldData.header = field.slice(0, windowsHeaderBoundary);
    579 			fieldData.body = field.slice(windowsHeaderBoundary+4);
    580 		} else {
    581 			throw new Error('Malformed multipart/form-data body');
    582 		}
    583 		
    584 		let contentDisposition = contentDispositionRe.exec(fieldData.header);
    585 		if (contentDisposition) {
    586 			for (let nameVal of contentDisposition[1].split(';')) {
    587 				nameVal.split('=');
    588 				fieldData[nameVal[0]] = nameVal.length > 1 ? nameVal[1] : null;
    589 			}
    590 		}
    591 		results.push(fieldData);
    592 	}
    593 	return results;
    594 };
    595 
    596 
    597 /**
    598  * Endpoints for the HTTP server
    599  *
    600  * Each endpoint should take the form of an object. The init() method of this object will be passed:
    601  *     method - the method of the request ("GET" or "POST")
    602  *     data - the query string (for a "GET" request) or POST data (for a "POST" request)
    603  *     sendResponseCallback - a function to send a response to the HTTP request. This can be passed
    604  *                            a response code alone (e.g., sendResponseCallback(404)) or a response
    605  *                            code, MIME type, and response body
    606  *                            (e.g., sendResponseCallback(200, "text/plain", "Hello World!"))
    607  *
    608  * See connector/server_connector.js for examples
    609  */
    610 Zotero.Server.Endpoints = {}