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 = {}