├── .rspec ├── .ruby-version ├── .ruby-gemset ├── Rakefile ├── .document ├── .travis.yml ├── lib └── grocer │ ├── pushpackager.rb │ └── pushpackager │ ├── version.rb │ ├── icon.rb │ ├── icon_set.rb │ ├── website.rb │ └── package.rb ├── Gemfile ├── .gitignore ├── spec ├── spec_helper.rb ├── support │ └── test_files.rb └── package_spec.rb ├── LICENSE.txt ├── grocer-pushpackager.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.0.0-p195 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | pushpackager 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | script: rspec spec 6 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager.rb: -------------------------------------------------------------------------------- 1 | require "grocer/pushpackager/version" 2 | require "grocer/pushpackager/package" 3 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager/version.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | module Pushpackager 3 | VERSION = "0.0.1" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in grocer-pushpackager.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 3 | require 'coveralls' 4 | Coveralls.wear! 'rails' 5 | require 'pry' 6 | require 'rspec' 7 | 8 | require 'grocer/pushpackager' 9 | 10 | # Requires supporting files with custom matchers and macros, etc, 11 | # # in ./support/ and its subdirectories. 12 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 13 | RSpec.configure do |config| 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager/icon.rb: -------------------------------------------------------------------------------- 1 | module Grocer 2 | module Pushpackager 3 | class Icon 4 | attr_reader :name, :contents 5 | def initialize(size) 6 | @name = "icon_#{size}.png" 7 | self 8 | end 9 | 10 | def from_file file 11 | @contents = file.read 12 | file.close 13 | self 14 | end 15 | 16 | def valid? 17 | raise ArgumentError, "invalid icon name" unless @name 18 | raise ArgumentError, "missing contents" unless @contents 19 | true 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager/icon_set.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require_relative 'icon' 3 | 4 | module Grocer 5 | module Pushpackager 6 | class IconSet 7 | include Enumerable 8 | extend Forwardable 9 | def_delegators :@icons, :each, :<< 10 | 11 | def initialize(config = {}) 12 | raise ArgumentError, "Missing icon set" unless config[:iconSet] 13 | @icons = [] 14 | config[:iconSet].each do |size, file| 15 | @icons << Icon.new(size).from_file(file) 16 | end 17 | end 18 | 19 | def valid? 20 | fail unless @icons.select{|i| i.valid?}.count == @icons.count 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/test_files.rb: -------------------------------------------------------------------------------- 1 | def test_icon 2 | file = Tempfile.new('icon') 3 | file.write("hello world") 4 | file.rewind 5 | file.unlink 6 | file 7 | end 8 | 9 | def test_ssl_pair 10 | key = OpenSSL::PKey::RSA.new(512) 11 | name = OpenSSL::X509::Name.parse("CN=auralis/DC=topalis/DC=com") 12 | cert = OpenSSL::X509::Certificate.new() 13 | cert.version = 2 14 | cert.serial = 0 15 | cert.not_before = Time.new() 16 | cert.not_after = cert.not_before + (60*60*24*365) 17 | cert.public_key = key.public_key 18 | cert.subject = name 19 | 20 | # Sign cert 21 | cert.issuer = name 22 | cert.sign(key, OpenSSL::Digest::SHA1.new()) 23 | 24 | {certificate: cert, key: key} 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Kurt Nelson 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /grocer-pushpackager.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'grocer/pushpackager/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "grocer-pushpackager" 8 | spec.version = Grocer::Pushpackager::VERSION 9 | spec.authors = ["Kurt Nelson"] 10 | spec.email = ["kurtisnelson@gmail.com"] 11 | spec.description = %q{Builds push packages for OS X Mavericks web notifications} 12 | spec.summary = <<-DESC 13 | To use push notifications to Safari/OS X with the APN service, 14 | you must build a signed push package that is generated by your web server 15 | for every user. This gem handles building that package. 16 | DESC 17 | spec.homepage = "https://github.com/grocer/grocer-pushpackager" 18 | spec.license = "MIT" 19 | 20 | spec.files = `git ls-files`.split($/) 21 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 22 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_development_dependency "bundler", "~> 1.3" 26 | spec.add_development_dependency 'pry' 27 | spec.add_development_dependency 'coveralls' 28 | spec.add_development_dependency 'rspec' 29 | spec.add_development_dependency 'rake' 30 | spec.add_dependency 'rubyzip' 31 | end 32 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager/website.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'json' 3 | 4 | module Grocer 5 | module Pushpackager 6 | class Website 7 | attr_accessor :authentication_token 8 | attr_reader :website_name, :push_id, :allowed_domains, :url_format_string, :web_service_url 9 | 10 | def initialize(config = {}) 11 | @website_name = config[:websiteName] 12 | @push_id = config[:websitePushID] 13 | @allowed_domains = [] 14 | if config[:allowedDomains] 15 | @allowed_domains = config[:allowedDomains].map{|domain| URI(domain)} 16 | end 17 | @url_format_string = config[:urlFormatString] 18 | @web_service_url = URI(config[:webServiceURL]) 19 | @authentication_token = config[:authenticationToken] 20 | end 21 | 22 | def valid? 23 | raise ArgumentError if not @website_name or @website_name.empty? 24 | raise ArgumentError if not @push_id or @push_id.empty? 25 | raise ArgumentError if not @url_format_string or @url_format_string.empty? 26 | raise ArgumentError if not @web_service_url or @web_service_url.to_s.empty? or not ['http', 'https'].include?(@web_service_url.scheme) 27 | raise ArgumentError if not @authentication_token or @authentication_token.empty? 28 | raise ArgumentError, "Allowed domains" unless @allowed_domains.count > 0 29 | true 30 | end 31 | 32 | def to_json 33 | raise "Invalid website" unless valid? 34 | { websiteName: @website_name.to_s, 35 | websitePushID: @push_id.to_s, 36 | allowedDomains: @allowed_domains.map{|domain| domain.to_s}, 37 | urlFormatString: @url_format_string.to_s, 38 | authenticationToken: @authentication_token.to_s, 39 | webServiceURL: @web_service_url.to_s, 40 | }.to_json 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/package_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Grocer::Pushpackager::Package do 4 | subject {Grocer::Pushpackager::Package} 5 | it "can be initialized as a valid object" do 6 | package = subject.new({ 7 | websiteName: "Bay Airlines", 8 | websitePushID: "web.com.example.domain", 9 | allowedDomains: ["http://domain.example.com"], 10 | urlFormatString: "http://domain.example.com/%@/?flight=%@", 11 | webServiceURL: "https://example.com/push", 12 | iconSet: { }, 13 | certificate: '', 14 | key: '' 15 | }) 16 | expect{package.valid?}.to raise_error ArgumentError 17 | package.authentication_token = "19f8d7a6e9fb8a7f6d9330dabe" 18 | package.should be_valid 19 | end 20 | 21 | context "complete, valid package" do 22 | before :all do 23 | @pair = test_ssl_pair 24 | end 25 | 26 | subject do 27 | Grocer::Pushpackager::Package.new({ 28 | websiteName: "Bay Airlines", 29 | websitePushID: "web.com.example.domain", 30 | allowedDomains: ["http://domain.example.com"], 31 | urlFormatString: "http://domain.example.com/%@/?flight=%@", 32 | authenticationToken: "19f8d7a6e9fb8a7f6d9330dabe", 33 | webServiceURL: "https://example.com/push", 34 | certificate: @pair[:certificate], 35 | key: @pair[:key], 36 | iconSet: { 37 | :'16x16' => test_icon, 38 | :'16x16@2x' => test_icon, 39 | :'32x32' => test_icon, 40 | :'32x32@2x' => test_icon, 41 | :'128x128' => test_icon, 42 | :'128x128@2x' => test_icon 43 | } 44 | }) 45 | end 46 | 47 | it{should be_valid} 48 | 49 | it "generates a non-zero file" do 50 | subject.send(:build_zip).string.should have_at_least(8).length 51 | end 52 | 53 | it "writes out file" do 54 | package = subject.file 55 | file = File.new('tmp/package.zip', 'w') 56 | file.write(package.open.read) 57 | package.unlink 58 | file.should have_at_least(1).length 59 | file.close 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grocer::Pushpackager 2 | [![Build Status](https://secure.travis-ci.org/grocer/grocer-pushpackager.png)](http://travis-ci.org/grocer/grocer-pushpackager) 3 | [![Gem Version](https://badge.fury.io/rb/grocer-pushpackager.png)](http://badge.fury.io/rb/grocer-pushpackager) 4 | [![Code Climate](https://codeclimate.com/github/grocer/grocer-pushpackager.png)](https://codeclimate.com/github/grocer/grocer-pushpackager) 5 | [![Coverage Status](https://coveralls.io/repos/grocer/grocer-pushpackager/badge.png?branch=master)](https://coveralls.io/r/grocer/grocer-pushpackager) 6 | [Documentation](http://rubydoc.info/gems/grocer-pushpackager/) 7 | 8 | Builds a PushPackage for Apple's APN for use with Safari/Mavericks 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | gem 'grocer-pushpackager' 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install grocer-pushpackager 22 | 23 | ## Usage 24 | 25 | ```ruby 26 | test_icon = File.open('icon.png') 27 | @pair = { 28 | key: OpenSSL::PKey::RSA.new(File.read('rsa.pem'), 'my pass phrase'), 29 | certificate: OpenSSL::X509::Certificate.new(File.read('apple-dev.cer')) 30 | } 31 | ``` 32 | 33 | ```ruby 34 | builder = Grocer::Pushpackager::Package.new({ 35 | websiteName: "Bay Airlines", 36 | websitePushID: "web.com.example.domain", 37 | allowedDomains: ["http://domain.example.com"], 38 | urlFormatString: "http://domain.example.com/%@/?flight=%@", 39 | authenticationToken: "19f8d7a6e9fb8a7f6d9330dabe", 40 | webServiceURL: "https://example.com/push", 41 | certificate: @pair[:certificate], 42 | key: @pair[:key], 43 | iconSet: { 44 | :'16x16' => test_icon, 45 | :'16x16@2x' => test_icon, 46 | :'32x32' => test_icon, 47 | :'32x32@2x' => test_icon, 48 | :'128x128' => test_icon, 49 | :'128x128@2x' => test_icon 50 | } 51 | }) 52 | builder.file # A closed Tempfile that can be read 53 | builder.buffer # A string buffer that can be streamed out to a client 54 | ``` 55 | 56 | ## Contributing 57 | 58 | 1. Fork it 59 | 2. Create your feature branch (`git checkout -b my-new-feature`) 60 | 3. Commit your changes (`git commit -am 'Add some feature'`) 61 | 4. Push to the branch (`git push origin my-new-feature`) 62 | 5. Create new Pull Request 63 | -------------------------------------------------------------------------------- /lib/grocer/pushpackager/package.rb: -------------------------------------------------------------------------------- 1 | require 'zip/zip' 2 | require 'digest/sha1' 3 | require 'openssl' 4 | require_relative 'icon_set' 5 | require_relative 'website' 6 | 7 | module Grocer 8 | module Pushpackager 9 | class Package 10 | attr_accessor :key, :certificate, :icon_set, :website 11 | 12 | def initialize(config = {}) 13 | @icon_set = IconSet.new(config) 14 | @website = Website.new(config) 15 | @certificate = config[:certificate] 16 | @key = config[:key] 17 | end 18 | 19 | def authentication_token= value 20 | @website.authentication_token = value 21 | @signature = nil 22 | @website_json = nil 23 | @manifest_json = nil 24 | end 25 | 26 | def valid? 27 | @icon_set.valid? 28 | @website.valid? 29 | raise ArgumentError, "Missing private key" unless @key 30 | raise ArgumentError, "Missing certificate" unless @certificate 31 | true 32 | end 33 | 34 | def file 35 | raise unless self.valid? 36 | package = Tempfile.new('package') 37 | package.write(build_zip.string) 38 | package.close 39 | package 40 | end 41 | 42 | def buffer 43 | raise unless self.valid? 44 | build_zip.string 45 | end 46 | 47 | private 48 | def website_json 49 | @website_json ||= @website.to_json 50 | end 51 | 52 | def manifest_json 53 | return @manifest_json if @manifest_json 54 | @manifest_json = { 55 | :"website.json" => Digest::SHA1.hexdigest(website_json) 56 | }.merge(icon_set_manifest).to_json 57 | end 58 | 59 | def icon_set_manifest 60 | manifest = { } 61 | @icon_set.each do |icon| 62 | manifest[:"icon.iconset\/#{icon.name}"] = Digest::SHA1.hexdigest(icon.contents) 63 | end 64 | manifest 65 | end 66 | 67 | def signature 68 | return @signature if @signature 69 | @signature = OpenSSL::PKCS7::sign(@certificate, @key, manifest_json, [], OpenSSL::PKCS7::DETACHED) 70 | end 71 | 72 | def build_zip 73 | buffer = Zip::ZipOutputStream.write_buffer do |out| 74 | @icon_set.each do |icon| 75 | out.put_next_entry("icon.iconset/#{icon.name}") 76 | out.write icon.contents 77 | end 78 | out.put_next_entry('website.json') 79 | out.write website_json 80 | out.put_next_entry('manifest.json') 81 | out.write manifest_json 82 | out.put_next_entry('signature') 83 | out.write signature.to_der 84 | end 85 | buffer 86 | end 87 | end 88 | end 89 | end 90 | --------------------------------------------------------------------------------