UNPKG

passbook

Version:
425 lines (375 loc) 11.8 kB
// Generate a pass file. var applyImageMethods = require("./images"); var Crypto = require("crypto"); var EventEmitter = require("events").EventEmitter; var execFile = require("child_process").execFile; var Stream = require("stream"); var inherits = require("util").inherits; var File = require("fs"); var HTTP = require("follow-redirects").http; var HTTPS = require("follow-redirects").https; var Path = require("path"); var Zip = require("./zip"); var zlib = require("zlib"); // Top-level pass fields. var TOP_LEVEL = [ "appLaunchURL", "associatedStoreIdentifiers", "authenticationToken", "backgroundColor", "barcode", "description", "foregroundColor", "labelColor", "locations", "logoText", "organizationName", "relevantDate", "serialNumber", "suppressStripShine", "webServiceURL", "beacons"]; // These top level fields are required for a valid pass. var REQUIRED_TOP_LEVEL = [ "description", "organizationName", "passTypeIdentifier", "serialNumber", "teamIdentifier" ]; // Pass structure keys. var STRUCTURE_FIELDS = [ "auxiliaryFields", "backFields", "headerFields", "primaryFields", "secondaryFields" ]; // These images are required for a valid pass. var REQUIRED_IMAGES = [ "icon", "logo" ]; // Create a new pass. // // template - The template // fields - Pass fields (description, serialNumber, logoText) function Pass(template, fields, images) { this.template = template; this.fields = cloneObject(fields); // Structure is basically reference to all the fields under a given style // key, e.g. if style is coupon then structure.primaryFields maps to // fields.coupon.primaryFields. var style = template.style; this.structure = this.fields[style]; if (!this.structure) this.structure = this.fields[style] = {}; this.images = cloneObject(images); } inherits(Pass, EventEmitter); applyImageMethods(Pass); // Accessor methods for top-level fields (description, serialNumber, logoText, // etc). // // Call with an argument to set field and return self, call with no argument to // get field value. // // pass.description("Unbelievable discount"); // console.log(pass.description()); TOP_LEVEL.forEach(function(key) { Pass.prototype[key] = function(value) { if (arguments.length === 0) { return this.fields[key]; } else { this.fields[key] = value; return this; } }; }); // Accessor methods for structure fields (primaryFields, backFields, etc). // // For example: // // pass.headerFields.add("time", "The Time", "10:00AM"); // pass.backFields.add("url", "Web site", "http://example.com"); STRUCTURE_FIELDS.forEach(function(key) { Pass.prototype.__defineGetter__(key, function() { return new Fields(this, key); }); }); // Field accessors. function Fields(pass, key) { this.pass = pass; this.key = key; } // Adds a field to the end of the list. // // You can call this method with three/four arguments: // key - Field key // label - Field label (optional) // value - Field value // options - Other field options (e.g. dateStyle) // // You can call this method with a single object that contains all field // properties (key, label, etc). // // You can also call with an array of either one. // // Returns self. Fields.prototype.add = function(key, label, value, options) { var field, k; if (arguments.length > 1) { this.remove(key); field = { key: key, value: value }; if (label) field.label = label; if (options) { for (k in options) field[k] = options[k]; } this.all().push(field); } else if (Array.isArray(arguments[0])) { var array = arguments[0]; for (var i in array) this.add.call(this, array[i]); } else { var properties = arguments[0]; key = properties.key; this.remove(key); field = {}; for (k in properties) field[k] = properties[k]; this.all().push(field); } return this; }; // Returns a field. // // If field exists, returns an object with: // key - Field key // label - Field label (optional) // value - Field value // Other field options (e.g. dateStyle) Fields.prototype.get = function(key) { var fields = this.pass.structure[this.key]; if (fields) { for (var i in fields) { var field = fields[i]; if (field.key == key) return field; } } return null; }; // Returns an array of all fields. Fields.prototype.all = function() { var fields = this.pass.structure[this.key]; if (!fields) this.pass.structure[this.key] = fields = []; return fields; }; // Removes a given field. Fields.prototype.remove = function(key) { var fields = this.pass.structure[this.key]; if (fields) { for (var i in fields) { if (fields[i].key == key) { fields.splice(i, 1); break; } } } return this; }; // Removes all fields. Fields.prototype.clear = function() { this.pass.structure[this.key] = []; return this; }; // Validate pass, throws error if missing a mandatory top-level field or image. Pass.prototype.validate = function() { for (var i in REQUIRED_TOP_LEVEL) { var k1 = REQUIRED_TOP_LEVEL[i]; if (!this.fields[k1]) throw new Error("Missing field " + k1); } for (var j in REQUIRED_IMAGES) { var k2 = REQUIRED_IMAGES[j]; if (!this.images[k2]) throw new Error("Missing image " + k2 + ".png"); } }; // Returns the pass.json object (not a string). Pass.prototype.getPassJSON = function() { var fields = cloneObject(this.fields); fields.formatVersion = 1; return fields; }; // Pipe pass to a write stream. // // output - Write stream Pass.prototype.pipe = function(output) { var self = this; var zip = new Zip(output); var lastError; zip.on("error", function(error) { lastError = error; }); // Validate before attempting to create try { this.validate(); } catch (error) { process.nextTick(function() { self.emit("error", error); }); return output; } // Construct manifest here var manifest = {}; // Add file to zip and it's SHA to manifest function addFile(filename) { var file = zip.addFile(filename); var sha = new SHAWriteStream(manifest, filename, file); return sha; } // Create pass.json var passJson = Buffer.from(JSON.stringify(this.getPassJSON()), "utf-8"); addFile("pass.json").end(passJson, "utf8"); var expecting = 0; for (var key in this.images) { var filename = key.replace(/2x$/, "@2x") + ".png"; addImage(addFile(filename), this.images[key], function(error) { --expecting; if (error) lastError = error; if (expecting === 0) doneWithImages(); }); ++expecting; } function doneWithImages() { if (lastError) { zip.close(); self.emit("error", lastError); } else { process.nextTick(function() { self.signZip(zip, manifest, function(error) { if (error) { return self.emit("error", error); } zip.close(); zip.on("end", function() { self.emit("end"); }); zip.on("error", function(error) { self.emit("error", error); }); }); }); } } return output; }; // Use this to send pass as HTTP response. // // response - HTTP response // callback - Called when done sending/error occurred // // Adds appropriate headers and pipes pass to response. Pass.prototype.render = function(response, callback) { response.setHeader("Content-Type", "application/vnd.apple.pkpass"); if (callback) { this.on("error", callback); this.on("end", callback); } this.pipe(response); }; function addImage(file, source, callback) { if (typeof(source) == "string" || source instanceof String) { if (/^https?:/i.test(source)) { // URL var protocol = /^https:/i.test(source) ? HTTPS : HTTP; protocol.get(source, function(response) { if (response.statusCode == 200) { file.on("close", callback); if (response.headers['content-encoding'] === "gzip") { response.pipe(zlib.createGunzip()).pipe(file); } else { response.pipe(file); } response.resume(); } else callback(new Error("Server returned " + response.statusCode + " for " + source)); }).on("error", callback); } else { // Assume filename var stream = File.createReadStream(source); stream.pipe(file); file.on("close", callback); } } else if (source instanceof Buffer) { file.on("close", callback); file.write(source); file.end(); } else if (typeof(source) == "function") { try { source(file); callback(); } catch (error) { callback(error); } } else { // image is not a supported type callback(new Error("Cannot load image " + file.filename + ", must be String (filename), Buffer or function")); } } // Add manifest.json and signature files. Pass.prototype.signZip = function(zip, manifest, callback) { var json = JSON.stringify(manifest); // Add manifest.json zip.addFile("manifest.json").end(json, "utf-8"); // Create signature signManifest(this.template, json, function(error, signature) { if (!error) { // Write signature file zip.addFile("signature").end(signature); } callback(error); }); }; // Signs a manifest and returns the signature. function signManifest(template, manifest, callback) { var identifier = template.passTypeIdentifier().replace(/^pass./, ""); var args = [ "smime", "-sign", "-binary", "-signer", Path.resolve(template.keysPath, identifier + ".pem"), "-certfile", Path.resolve(template.keysPath, "wwdr.pem"), "-passin", "pass:" + template.password ]; var sign = execFile("openssl", args, { stdio: "pipe" }, function(error, stdout, stderr) { var trimmedStderr = stderr.trim(); // Windows outputs some unhelpful error messages, but still produces a valid signature if (error || (trimmedStderr && trimmedStderr.indexOf('- done') < 0)) { callback(new Error(stderr)); } else { var signature = stdout.split(/\r\n|\n\n/)[3]; callback(null, Buffer.from(signature, "base64")); } }); sign.stdin.write(manifest); sign.stdin.end(); } // Clone an object by copying all its properties and returning new object. // If the argument is missing or null, returns a new object. function cloneObject(object) { var clone = {}; if (object) { for (var key in object) clone[key] = object[key]; } return clone; } // -- Manifest output stream -- // A write stream that calculates SHA from the output and updates the manifest // accordingly. // // manifest - Manifest object // filename - Filename (manifest property to set) // output - Pipe to this output stream function SHAWriteStream(manifest, filename, output) { this.output = output; this.manifest = manifest; this.filename = filename; this.sha = Crypto.createHash("sha1"); output.on("close", this.emit.bind(this, "close")); output.on("error", this.emit.bind(this, "error")); } inherits(SHAWriteStream, Stream); SHAWriteStream.prototype.write = function(buffer, encoding) { this.output.write(buffer, encoding); this.sha.update(buffer, encoding); return true; }; SHAWriteStream.prototype.end = function(buffer, encoding) { if (buffer) this.write(buffer, encoding); this.output.end(); this.manifest[this.filename] = this.sha.digest("hex"); }; module.exports = Pass;