mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
529 lines
14 KiB
JavaScript
529 lines
14 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
// TODO enable once build infra supports test modules.
|
|
/*
|
|
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
|
|
|
const EXPORTED_SYMBOLS = [
|
|
"AITCServer10User",
|
|
"AITCServer10Server",
|
|
];
|
|
|
|
Cu.import("resource://testing-common/httpd.js");
|
|
*/
|
|
Cu.import("resource://services-crypto/utils.js");
|
|
Cu.import("resource://services-common/log4moz.js");
|
|
Cu.import("resource://services-common/utils.js");
|
|
|
|
/**
|
|
* Represents an individual user on an AITC 1.0 server.
|
|
*
|
|
* This type provides convenience APIs for interacting with an individual
|
|
* user's data.
|
|
*/
|
|
function AITCServer10User() {
|
|
this._log = Log4Moz.repository.getLogger("Services.Common.AITCServer");
|
|
this.apps = {};
|
|
}
|
|
AITCServer10User.prototype = {
|
|
appRecordProperties: {
|
|
origin: true,
|
|
manifestPath: true,
|
|
installOrigin: true,
|
|
installedAt: true,
|
|
modifiedAt: true,
|
|
receipts: true,
|
|
name: true,
|
|
deleted: true,
|
|
},
|
|
|
|
requiredAppProperties: [
|
|
"origin",
|
|
"manifestPath",
|
|
"installOrigin",
|
|
"installedAt",
|
|
"modifiedAt",
|
|
"name",
|
|
"receipts",
|
|
],
|
|
|
|
/**
|
|
* Obtain the apps for this user.
|
|
*
|
|
* This is a generator of objects representing the apps. Returns the original
|
|
* apps object normally or an abbreviated version if `minimal` is truthy.
|
|
*/
|
|
getApps: function getApps(minimal) {
|
|
let result;
|
|
|
|
for (let id in this.apps) {
|
|
let app = this.apps[id];
|
|
|
|
if (!minimal) {
|
|
yield app;
|
|
continue;
|
|
}
|
|
|
|
yield {origin: app.origin, modifiedAt: app.modifiedAt};
|
|
}
|
|
},
|
|
|
|
getAppByID: function getAppByID(id) {
|
|
return this.apps[id];
|
|
},
|
|
|
|
/**
|
|
* Adds an app to this user.
|
|
*
|
|
* The app record should be an object (likely from decoded JSON).
|
|
*/
|
|
addApp: function addApp(app) {
|
|
for (let k in app) {
|
|
if (!(k in this.appRecordProperties)) {
|
|
throw new Error("Unexpected property in app record: " + k);
|
|
}
|
|
}
|
|
|
|
for each (let k in this.requiredAppProperties) {
|
|
if (!(k in app)) {
|
|
throw new Error("Required property not in app record: " + k);
|
|
}
|
|
}
|
|
|
|
this.apps[this.originToID(app.origin)] = app;
|
|
},
|
|
|
|
/**
|
|
* Returns whether a user has an app with the specified ID.
|
|
*/
|
|
hasAppID: function hasAppID(id) {
|
|
return id in this.apps;
|
|
},
|
|
|
|
/**
|
|
* Delete an app having the specified ID.
|
|
*/
|
|
deleteAppWithID: function deleteAppWithID(id) {
|
|
delete this.apps[id];
|
|
},
|
|
|
|
/**
|
|
* Convert an origin string to an ID.
|
|
*/
|
|
originToID: function originToID(origin) {
|
|
let hash = CryptoUtils.UTF8AndSHA1(origin);
|
|
return CommonUtils.encodeBase64URL(hash, false);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* A fully-functional AITC 1.0 server implementation.
|
|
*
|
|
* Each server instance is capable of serving requests for multiple users.
|
|
* By default, users do not exist and requests to URIs for a specific user
|
|
* will result in 404s. To register a new user with an empty account, call
|
|
* createUser(). If you wish for HTTP requests for non-existing users to
|
|
* work, set autoCreateUsers to true and am empty user will be
|
|
* provisioned at request time.
|
|
*/
|
|
function AITCServer10Server() {
|
|
this._log = Log4Moz.repository.getLogger("Services.Common.AITCServer");
|
|
|
|
this.server = new nsHttpServer();
|
|
this.port = null;
|
|
this.users = {};
|
|
this.autoCreateUsers = false;
|
|
|
|
this._appsAppHandlers = {
|
|
GET: this._appsAppGetHandler,
|
|
PUT: this._appsAppPutHandler,
|
|
DELETE: this._appsAppDeleteHandler,
|
|
};
|
|
}
|
|
AITCServer10Server.prototype = {
|
|
ID_REGEX: /^[a-zA-Z0-9_-]{27}$/,
|
|
VERSION_PATH: "/1.0/",
|
|
|
|
/**
|
|
* Obtain the base URL the server can be accessed at as a string.
|
|
*/
|
|
get url() {
|
|
// Is this available on the nsHttpServer instance?
|
|
return "http://localhost:" + this.port + this.VERSION_PATH;
|
|
},
|
|
|
|
/**
|
|
* Start the server on a specified port.
|
|
*/
|
|
start: function start(port) {
|
|
if (!port) {
|
|
throw new Error("port argument must be specified.");
|
|
}
|
|
|
|
this.port = port;
|
|
|
|
this.server.registerPrefixHandler(this.VERSION_PATH,
|
|
this._generalHandler.bind(this));
|
|
this.server.start(port);
|
|
},
|
|
|
|
/**
|
|
* Stop the server.
|
|
*
|
|
* Calls the specified callback when the server is stopped.
|
|
*/
|
|
stop: function stop(cb) {
|
|
let handler = {onStopped: cb};
|
|
|
|
this.server.stop(handler);
|
|
},
|
|
|
|
createUser: function createUser(username) {
|
|
if (username in this.users) {
|
|
throw new Error("User already exists: " + username);
|
|
}
|
|
|
|
this._log.info("Registering user: " + username);
|
|
|
|
this.users[username] = new AITCServer10User();
|
|
this.server.registerPrefixHandler(this.VERSION_PATH + username + "/",
|
|
this._userHandler.bind(this, username));
|
|
|
|
return this.users[username];
|
|
},
|
|
|
|
/**
|
|
* Returns information for an individual user.
|
|
*
|
|
* The returned object contains functions to access and manipulate an
|
|
* individual user.
|
|
*/
|
|
getUser: function getUser(username) {
|
|
if (!(username in this.users)) {
|
|
throw new Error("user is not present in server: " + username);
|
|
}
|
|
|
|
return this.users[username];
|
|
},
|
|
|
|
/**
|
|
* HTTP handler for requests to /1.0/ which don't have a specific user
|
|
* registered.
|
|
*/
|
|
_generalHandler: function _generalHandler(request, response) {
|
|
let path = request.path;
|
|
this._log.info("Request: " + request.method + " " + path);
|
|
|
|
if (path.indexOf(this.VERSION_PATH) != 0) {
|
|
throw new Error("generalHandler invoked improperly.");
|
|
}
|
|
|
|
let rest = request.path.substr(this.VERSION_PATH.length);
|
|
if (!rest.length) {
|
|
throw HTTP_404;
|
|
}
|
|
|
|
if (!this.autoCreateUsers) {
|
|
throw HTTP_404;
|
|
}
|
|
|
|
let username;
|
|
let index = rest.indexOf("/");
|
|
if (index == -1) {
|
|
username = rest;
|
|
} else {
|
|
username = rest.substr(0, index);
|
|
}
|
|
|
|
this.createUser(username);
|
|
this._userHandler(username, request, response);
|
|
},
|
|
|
|
/**
|
|
* HTTP handler for requests for a specific user.
|
|
*
|
|
* This handles request routing to the appropriate handler.
|
|
*/
|
|
_userHandler: function _userHandler(username, request, response) {
|
|
this._log.info("Request: " + request.method + " " + request.path);
|
|
let path = request.path;
|
|
let prefix = this.VERSION_PATH + username + "/";
|
|
|
|
if (path.indexOf(prefix) != 0) {
|
|
throw new Error("userHandler invoked improperly.");
|
|
}
|
|
|
|
let user = this.users[username];
|
|
if (!user) {
|
|
throw new Error("User handler should not have been invoked for an " +
|
|
"unknown user!");
|
|
}
|
|
|
|
let requestTime = Date.now();
|
|
response.dispatchTime = requestTime;
|
|
response.setHeader("X-Timestamp", "" + requestTime);
|
|
|
|
let handler;
|
|
let remaining = path.substr(prefix.length);
|
|
|
|
if (remaining == "apps" || remaining == "apps/") {
|
|
this._log.info("Dispatching to apps index handler.");
|
|
handler = this._appsIndexHandler.bind(this, user, request, response);
|
|
} else if (!remaining.indexOf("apps/")) {
|
|
let id = remaining.substr("apps/".length);
|
|
|
|
this._log.info("Dispatching to app handler.");
|
|
handler = this._appsAppHandler.bind(this, user, id, request, response);
|
|
} else if (remaining == "devices" || !remaining.indexOf("devices/")) {
|
|
this._log.info("Dispatching to devices handler.");
|
|
handler = this._devicesHandler.bind(this, user,
|
|
remaining.substr("devices".length),
|
|
request, response);
|
|
} else {
|
|
throw HTTP_404;
|
|
}
|
|
|
|
try {
|
|
handler();
|
|
} catch (ex) {
|
|
if (ex instanceof HttpError) {
|
|
response.setStatusLine(request.httpVersion, ex.code, ex.description);
|
|
return;
|
|
}
|
|
|
|
this._log.warn("Exception when processing request: " +
|
|
CommonUtils.exceptionStr(ex));
|
|
throw ex;
|
|
}
|
|
},
|
|
|
|
_appsIndexHandler: function _appsIndexHandler(user, request, response) {
|
|
if (request.method != "GET") {
|
|
response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
|
|
response.setHeader("Accept", "GET");
|
|
|
|
return;
|
|
}
|
|
|
|
let options = this._getQueryStringParams(request);
|
|
for (let key in options) {
|
|
let value = options[key];
|
|
|
|
switch (key) {
|
|
case "after":
|
|
let time = parseInt(value, 10);
|
|
if (isNaN(time)) {
|
|
throw HTTP_400;
|
|
}
|
|
|
|
options.after = time;
|
|
break;
|
|
|
|
case "full":
|
|
// Value is irrelevant.
|
|
break;
|
|
|
|
default:
|
|
this._log.info("Unknown query string parameter: " + key);
|
|
throw HTTP_400;
|
|
}
|
|
}
|
|
|
|
let apps = [];
|
|
let newest = 0;
|
|
for each (let app in user.getApps(!("full" in options))) {
|
|
if (app.modifiedAt > newest) {
|
|
newest = app.modifiedAt;
|
|
}
|
|
|
|
if ("after" in options && app.modifiedAt <= options.after) {
|
|
continue;
|
|
}
|
|
|
|
apps.push(app);
|
|
}
|
|
|
|
if (request.hasHeader("X-If-Modified-Since")) {
|
|
let modified = parseInt(request.getHeader("X-If-Modified-Since"), 10);
|
|
if (modified >= newest) {
|
|
response.setStatusLine(request.httpVersion, 304, "Not Modified");
|
|
return;
|
|
}
|
|
}
|
|
|
|
let body = JSON.stringify({apps: apps});
|
|
response.setStatusLine(request.httpVersion, 200, "OK");
|
|
response.setHeader("X-Last-Modified", "" + newest);
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.bodyOutputStream.write(body, body.length);
|
|
},
|
|
|
|
_appsAppHandler: function _appAppHandler(user, id, request, response) {
|
|
if (!(request.method in this._appsAppHandlers)) {
|
|
response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
|
|
response.setHeader("Accept", Object.keys(this._appsAppHandlers).join(","));
|
|
|
|
return;
|
|
}
|
|
|
|
let handler = this._appsAppHandlers[request.method];
|
|
return handler.call(this, user, id, request, response);
|
|
},
|
|
|
|
_appsAppGetHandler: function _appsAppGetHandler(user, id, request, response) {
|
|
if (!user.hasAppID(id)) {
|
|
throw HTTP_404;
|
|
}
|
|
|
|
let app = user.getAppByID(id);
|
|
|
|
if (request.hasHeader("X-If-Modified-Since")) {
|
|
let modified = parseInt(request.getHeader("X-If-Modified-Since"), 10);
|
|
|
|
this._log.debug("Client time: " + modified + "; Server time: " +
|
|
app.modifiedAt);
|
|
|
|
if (modified >= app.modifiedAt) {
|
|
response.setStatusLine(request.httpVersion, 304, "Not Modified");
|
|
return;
|
|
}
|
|
}
|
|
|
|
let body = JSON.stringify(app);
|
|
response.setStatusLine(request.httpVersion, 200, "OK");
|
|
response.setHeader("X-Last-Modified", "" + response.dispatchTime);
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.bodyOutputStream.write(body, body.length);
|
|
},
|
|
|
|
_appsAppPutHandler: function _appsAppPutHandler(user, id, request, response) {
|
|
if (!request.hasHeader("Content-Type")) {
|
|
this._log.info("Request does not have Content-Type header.");
|
|
throw HTTP_400;
|
|
}
|
|
|
|
let ct = request.getHeader("Content-Type");
|
|
if (ct != "application/json" && ct.indexOf("application/json;") !== 0) {
|
|
this._log.info("Unknown media type: " + ct);
|
|
// TODO proper response headers.
|
|
throw HTTP_415;
|
|
}
|
|
|
|
let requestBody = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
|
|
this._log.debug("Request body: " + requestBody);
|
|
if (requestBody.length > 8192) {
|
|
this._log.info("Request body too long: " + requestBody.length);
|
|
throw HTTP_413;
|
|
}
|
|
|
|
let hadApp = user.hasAppID(id);
|
|
|
|
let app;
|
|
try {
|
|
app = JSON.parse(requestBody);
|
|
} catch (e) {
|
|
this._log.info("JSON parse error.");
|
|
throw HTTP_400;
|
|
}
|
|
|
|
// URL and record mismatch.
|
|
if (user.originToID(app.origin) != id) {
|
|
this._log.warn("URL ID and origin mismatch. URL: " + id + "; Record: " +
|
|
user.originToID(app.origin));
|
|
throw HTTP_403;
|
|
}
|
|
|
|
if (request.hasHeader("X-If-Unmodified-Since") && hadApp) {
|
|
let modified = parseInt(request.getHeader("X-If-Unmodified-Since"), 10);
|
|
let existing = user.getAppByID(id);
|
|
|
|
if (existing.modifiedAt > modified) {
|
|
this._log.info("Server modified after client.");
|
|
throw HTTP_412;
|
|
}
|
|
}
|
|
|
|
try {
|
|
app.modifiedAt = response.dispatchTime;
|
|
|
|
if (hadApp) {
|
|
app.installedAt = user.getAppByID(id).installedAt;
|
|
} else {
|
|
app.installedAt = response.dispatchTime;
|
|
}
|
|
|
|
user.addApp(app);
|
|
} catch (e) {
|
|
this._log.info("Error adding app: " + CommonUtils.exceptionStr(e));
|
|
throw HTTP_400;
|
|
}
|
|
|
|
let code = 201;
|
|
let status = "Created";
|
|
|
|
if (hadApp) {
|
|
code = 204;
|
|
status = "No Content";
|
|
}
|
|
|
|
response.setHeader("X-Last-Modified", "" + response.dispatchTime);
|
|
response.setStatusLine(request.httpVersion, code, status);
|
|
},
|
|
|
|
_appsAppDeleteHandler: function _appsAppDeleteHandler(user, id, request,
|
|
response) {
|
|
if (!user.hasAppID(id)) {
|
|
throw HTTP_404;
|
|
}
|
|
|
|
let existing = user.getAppByID(id);
|
|
if (request.hasHeader("X-If-Unmodified-Since")) {
|
|
let modified = parseInt(request.getHeader("X-If-Unmodified-Since"), 10);
|
|
|
|
if (existing.modifiedAt > modified) {
|
|
throw HTTP_412;
|
|
}
|
|
}
|
|
|
|
user.deleteAppWithID(id);
|
|
|
|
response.setHeader("X-Last-Modified", "" + response.dispatchTime);
|
|
response.setStatusLine(request.httpVersion, 204, "No Content");
|
|
},
|
|
|
|
_devicesHandler: function _devicesHandler(user, path, request, response) {
|
|
// TODO need to support full API.
|
|
// For now, we just assume it is a request for /.
|
|
response.setHeader("Content-Type", "application/json");
|
|
let body = JSON.stringify({devices: []});
|
|
|
|
response.setStatusLine(request.httpVersion, 200, "OK");
|
|
response.bodyOutputStream.write(body, body.length);
|
|
},
|
|
|
|
// Surely this exists elsewhere in the Mozilla source tree...
|
|
_getQueryStringParams: function _getQueryStringParams(request) {
|
|
let params = {};
|
|
for each (let chunk in request.queryString.split("&")) {
|
|
if (!chunk) {
|
|
continue;
|
|
}
|
|
|
|
let parts = chunk.split("=");
|
|
// TODO URL decode key and value.
|
|
if (parts.length == 1) {
|
|
params[parts[0]] = "";
|
|
} else {
|
|
params[parts[0]] = parts[1];
|
|
}
|
|
}
|
|
|
|
return params;
|
|
},
|
|
};
|
|
|