passbook
Version:
iOS Passbook for the Node hacker
425 lines (375 loc) • 11.8 kB
JavaScript
// 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;