boutique 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE.md CHANGED
@@ -1,19 +1,23 @@
1
- Copyright (C) 2011 by Hugh Bien
1
+ Copyright (c) 2012, Hugh Bien
2
+ All rights reserved.
2
3
 
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
4
+ Redistribution and use in source and binary forms, with or without modification,
5
+ are permitted provided that the following conditions are met:
9
6
 
10
- The above copyright notice and this permission notice shall be included in
11
- all copies or substantial portions of the Software.
7
+ Redistributions of source code must retain the above copyright notice, this list
8
+ of conditions and the following disclaimer.
12
9
 
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
- THE SOFTWARE.
10
+ Redistributions in binary form must reproduce the above copyright notice, this
11
+ list of conditions and the following disclaimer in the documentation and/or
12
+ other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
18
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
21
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md CHANGED
@@ -1,57 +1,34 @@
1
1
  Description
2
2
  ===========
3
3
 
4
- Boutique is a Sinatra module for selling digital goods. Still under development.
4
+ Boutique is a Sinatra app for drip emails (and soon-to-be product checkouts).
5
+ Still in development!
5
6
 
6
7
  Installation
7
8
  ============
8
9
 
9
- % gem install boutique
10
+ $ gem install boutique
10
11
 
11
- You'll need some private/public certificates:
12
-
13
- % openssl genrsa -out private.pem 1024
14
- % openssl req -new -key private.pem -x509 -days 1095 -out public.pem
15
-
16
- Make sure the email you use is the same as your PayPal merchant email, or else
17
- PayPal will reject it.
18
-
19
- You can download PayPal's public certificate under `Profile > Encrypted
20
- Payment Settings`. Click the download button for PayPal Public Certificate.
21
- Rename this certificate to something like `paypal.pem`.
22
-
23
- While you're there, upload the `public.pem` certificate you just generated. Copy
24
- the `Cert ID`, you'll use it for the `pem_cert_id` field in your config. You
25
- can block unencrypted requests from `Website Payment Preferences`.
26
-
27
- Setup a `config.ru` file and run it like any other Sinatra application. You
28
- can configure what products you sell here:
12
+ Setup a `config.ru` file and run it like any other Sinatra app:
29
13
 
30
14
  require 'rubygems'
31
15
  require 'boutique'
32
16
 
33
17
  Boutique.configure do |c|
34
18
  c.dev_email 'dev@mailinator.com'
35
- c.pem_cert_id 'LONGCERTID'
36
- c.pem_private '/path/to/private.pem'
37
- c.pem_public '/path/to/public.pem'
38
- c.pem_paypal '/path/to/paypal.pem'
19
+ c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
39
20
  c.download_dir '/path/to/download'
40
21
  c.download_path '/download'
41
- c.db_adapter 'mysql'
42
- c.db_host 'localhost'
43
- c.db_username 'root'
44
- c.db_password 'secret'
45
- c.db_database 'boutique'
46
- c.pp_email 'paypal_biz@mailinator.com'
47
- c.pp_url 'https://www.sandbox.paypal.com/cgi-bin/webscr'
22
+
23
+ c.db_options(adapter: 'postgresql', host: 'localhost',
24
+ username: 'root', password: 'secret', database: 'boutique')
25
+ c.email_options(via: :smtp, via_options: {host: 'smtp.example.org'})
48
26
  end
49
27
 
50
- Boutique.product('icon-set') do |p|
51
- p.name 'Icon Set'
52
- p.files '/path/to/icon-set.tgz' # array for multiple files
53
- p.price 10.5
54
- p.return_url 'http://zincmade.com/thankyou'
28
+ Boutique.list('learn-ruby') do |l|
29
+ l.from 'Hugh <hugh@mailinator.com>'
30
+ l.emails '/path/to/emails-dir'
31
+ l.url 'http://example.com'
55
32
  end
56
33
 
57
34
  run Boutique::App if !ENV['BOUTIQUE_CMD']
@@ -61,29 +38,77 @@ Stick this in your `bashrc` or `zshrc`:
61
38
  BOUTIQUE_CONFIG='/path/to/config.ru'
62
39
 
63
40
  Now setup the database tables (assuming you've already created the database and
64
- credentials):
41
+ credentials) and stick the `.css` and `.js` files in your project. Note that
42
+ `boutique.js` is dependent on jQuery.
43
+
44
+ $ boutique --migrate
45
+ $ boutique --assets
46
+ new -- boutique.js
47
+ new -- boutique.css
48
+ $ mv boutique.js boutique.css /path/to/project/assets/.
49
+
50
+ Drip Emails
51
+ ===========
52
+
53
+ Emails can be written in any templating format that `Tilt` accepts. Stick them
54
+ in `/path/to/emails-dir` (configured above in `config.ru`). Emails use
55
+ front-matter YAML for passing information to Boutique. The required fields are
56
+ `day`, `subject`, and `key`. The day is how many days should pass until the
57
+ email is sent. The key is a unique key assigned to each email to guard against
58
+ sending multiples to the same recipient. You'll also have access to three
59
+ local variables:
60
+
61
+ * `subscribe_url` - URL to open subscribe UI
62
+ * `confirm_url` - URL for double opt-in confirmation
63
+ * `unsubscribe_url` - URL to unsubscribe in one click
65
64
 
66
- % boutique --migrate
65
+ Here's an example email:
67
66
 
68
- With the settings above, a normal flow would look like:
67
+ ---
68
+ day: 1
69
+ subject: First Email
70
+ key: first-email
71
+ ---
69
72
 
70
- 1. On your site, link the user to `/buy/icon-set/` to purchase
71
- 2. User is redirected to paypal
72
- 3. After completing paypal, user is redirected to
73
- `http://zincmade.com/thankyou?b=order-id`
74
- 4. On this page, issue an AJAX request to `/purchases/order-id`.
75
- The `downloads` field of the JSON will include the download URLs.
73
+ Hi,
76
74
 
77
- Usage
78
- =====
75
+ This is the first email in the series.
79
76
 
80
- The web application is for customers, to get information about your products use
81
- the included command line application.
77
+ Thanks,
78
+ - Hugh
82
79
 
83
- % boutique --stats productcode
84
- % boutique --expire
85
- % boutique --expire id
86
- % boutique --delete id
80
+ [Click here to unsubscribe.](<%= unsubscribe_url %>)
81
+
82
+ This will be in the file `/path/to/emails-dir/first-email.md.erb`. Based on
83
+ the file extensions, Boutique will run it through ERB first followed by Markdown.
84
+
85
+ Also note, the directory should contain a special **zero day** email. This is
86
+ the email used to confirm when a new person signs up, also called the double
87
+ opt-in email:
88
+
89
+ ---
90
+ day: 0
91
+ subject: Please confirm your email address
92
+ key: confirm-email
93
+ ---
94
+
95
+ Hi,
96
+
97
+ Thanks for signing up. But wait, you're not done yet! Please
98
+ [click here to confirm your email address.](<%= confirm_url %>)
99
+
100
+ If you subscribed or received this email by mistake, please feel free to
101
+ ignore it. You will not receive any further emails.
102
+
103
+ Thanks!
104
+ - Hugh
105
+
106
+ Emails are sent out using the command line tool `boutique --drip`. This should
107
+ be run everyday. It's idempotent, so it's fine if it gets run multiple times a
108
+ day by mistake. Use cron to schedule drips:
109
+
110
+ $ crontab -e
111
+ 0 8 * * * boutique --drip
87
112
 
88
113
  Development
89
114
  ===========
@@ -91,13 +116,20 @@ Development
91
116
  Tests are setup to run individually via `ruby test/*_test.rb` or run them all
92
117
  via `rake`.
93
118
 
119
+ To start the server for local development:
120
+
121
+ $ BOUTIQUE_DEV=1 shotgun
122
+
94
123
  TODO
95
124
  ====
96
125
 
97
- * BUG: logs are required especially for errors
126
+ * implement UI opening on subscribe_url
127
+ * switch to Stripe
128
+ * add customizable? email integration for purchase receipts + recover
129
+ * add re-usable UI for purchasing, downloading, recover
98
130
 
99
131
  License
100
132
  =======
101
133
 
102
- Copyright 2011 Hugh Bien - http://hughbien.com.
103
- Released under MIT License, see LICENSE.md for more info.
134
+ Copyright Hugh Bien - http://hughbien.com.
135
+ Released under BSD License, see LICENSE.md for more info.
data/boutique CHANGED
@@ -9,36 +9,24 @@ ENV['BOUTIQUE_CMD'] = '1'
9
9
 
10
10
  module Boutique
11
11
  class Command
12
- def self.delete(id)
13
- purchase = expire(id)
14
- purchase.destroy if !purchase.nil?
12
+ JS_FILE = File.join(File.dirname(__FILE__), 'public/boutique/script.js')
13
+ CSS_FILE = File.join(File.dirname(__FILE__), 'public/boutique/styles.css')
14
+
15
+ def self.assets
16
+ File.exist?('boutique.js') ?
17
+ puts("exists -- boutique.js") :
18
+ FileUtils.copy(JS_FILE, "boutique.js") || puts(" new -- boutique.js")
19
+ File.exist?('boutique.css') ?
20
+ puts("exists -- boutique.css") :
21
+ FileUtils.copy(CSS_FILE, "boutique.css") || puts(" new -- boutique.css")
15
22
  end
16
23
 
17
- def self.expire(id)
24
+ def self.drip
18
25
  load_config
19
- purchase = Boutique::Purchase.get(id.to_i)
20
- if purchase.nil?
21
- puts "Purchase #{id} not found"
22
- return
26
+ Boutique::List.to_a.each do |list_key|
27
+ emailer = Boutique::Emailer.new(List[list_key])
28
+ emailer.drip
23
29
  end
24
- purchase.downloads.each do |download|
25
- FileUtils.rm_rf(File.dirname(
26
- "#{Boutique.config.download_dir}#{download.sub(Boutique.config.download_path, '')}"))
27
- end
28
- purchase
29
- end
30
-
31
- def self.expire_old
32
- load_config
33
- threshold = (Date.today - 1).strftime("%Y%m%d")
34
- dirs = Dir.glob("#{Boutique.config.download_dir}/*").
35
- map {|f| File.basename(f) }.
36
- select {|f| f < threshold }
37
- dirs.each {|d| FileUtils.rm_rf("#{Boutique.config.download_dir}/#{d}") }
38
-
39
- Boutique::Purchase.all(
40
- :transaction_id => nil,
41
- :created_at.lt => (Date.today - 1)).destroy
42
30
  end
43
31
 
44
32
  def self.migrate
@@ -49,35 +37,6 @@ module Boutique
49
37
  load_config
50
38
  end
51
39
 
52
- def self.stats(code)
53
- load_config
54
- product = Boutique::Product.first(:code => code)
55
- if product.nil?
56
- puts "Product #{code} not found"
57
- return
58
- end
59
-
60
- puts "Code: #{product.code}"
61
- puts "Name: #{product.name}"
62
- puts "Files: #{product.files.join("\n ")}"
63
- printf("Price: $%.2f\n" % product.price)
64
- puts "Return: #{product.return_url}"
65
- puts "Email: #{product.support_email}"
66
-
67
- puts ('-' * 40)
68
-
69
- purchases = product.purchases
70
- puts "* #{purchases.select {|p| p.completed?}.size} purchases have been completed"
71
- puts "* #{purchases.reduce(0) {|sum,p| sum + p.counter}} downloads linked"
72
-
73
- puts ('-' * 40)
74
-
75
- puts "id, links, email" if purchases.size > 0
76
- purchases.sort_by {|p| p.counter }.reverse.each do |purchase|
77
- puts "* #{purchase.id}, #{purchase.counter}, #{purchase.email}" if purchase.completed?
78
- end
79
- end
80
-
81
40
  private
82
41
  def self.load_config
83
42
  load(ENV['BOUTIQUE_CONFIG'] || 'config.ru')
@@ -89,11 +48,9 @@ ARGV.options do |o|
89
48
  o.set_summary_indent(' ')
90
49
  o.banner = "Usage: #{File.basename($0)} [OPTION]"
91
50
  o.define_head "Admin for boutique"
92
- o.on('-d', '--delete [id]', 'delete purchase') { |id| Boutique::Command.delete(id); exit }
93
- o.on('-e', '--expire [id]', 'expire purchase') { |id| Boutique::Command.expire(id); exit }
94
- o.on('-E', '--expire-old', 'expire old data') { Boutique::Command.expire_old; exit }
51
+ o.on('-a', '--assets', 'create css/js assets') { Boutique::Command.assets; exit }
52
+ o.on('-d', '--drip', 'drip out emails') { Boutique::Command.drip; exit }
95
53
  o.on('-m', '--migrate', 'initial migration') { Boutique::Command.migrate; exit }
96
- o.on('-s', '--stats [product]', String, 'show stats on product') { |p| Boutique::Command.stats(p); exit }
97
54
  o.on('-h', '--help', 'show this help message') { puts o; exit }
98
55
  o.parse!
99
56
  puts o
data/config.ru CHANGED
@@ -1,28 +1,29 @@
1
1
  require File.expand_path('lib/boutique', File.dirname(__FILE__))
2
2
 
3
- Boutique.configure(!ENV['BOUTIQUE_CMD'].nil?) do |c|
4
- c.dev_email 'dev@localhost'
5
- c.pem_cert_id 'LONGCERTID'
6
- c.pem_private File.expand_path('certs/private.pem', File.dirname(__FILE__))
7
- c.pem_public File.expand_path('certs/public.pem', File.dirname(__FILE__))
8
- c.pem_paypal File.expand_path('certs/paypal.pem', File.dirname(__FILE__))
9
- c.download_path '/download'
10
- c.download_dir File.expand_path('temp', File.dirname(__FILE__))
11
- c.db_adapter 'sqlite3'
12
- c.db_host 'localhost'
13
- c.db_username 'root'
14
- c.db_password 'secret'
15
- c.db_database 'db.sqlite3'
16
- c.pp_email 'paypal_biz@mailinator.com'
17
- c.pp_url 'http://localhost'
3
+ Boutique.configure(ENV['BOUTIQUE_DEV'] || !ENV['BOUTIQUE_CMD'].nil?) do |c|
4
+ #c.dev_email 'dev@localhost'
5
+ c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
6
+ c.download_path '/download'
7
+ c.download_dir File.expand_path('temp', File.dirname(__FILE__))
8
+ c.db_options(
9
+ adapter: 'sqlite3',
10
+ host: 'localhost',
11
+ username: 'root',
12
+ password: 'secret',
13
+ database: 'db.sqlite3')
14
+ c.email_options(via: :sendmail)
18
15
  end
19
16
 
20
17
  Boutique.product('readme') do |p|
21
- p.name 'README document'
22
- p.files File.expand_path('README.md', File.dirname(__FILE__))
23
- p.price 1.5
24
- p.return_url 'http://localhost'
25
- p.support_email 'support@localhost'
18
+ p.from 'support@localhost'
19
+ p.files File.expand_path('README.md', File.dirname(__FILE__))
20
+ p.price 1.5
21
+ end
22
+
23
+ Boutique.list('learn-ruby') do |l|
24
+ l.from 'Hugh <hugh@localhost>'
25
+ l.emails File.expand_path('emails', File.dirname(__FILE__))
26
+ l.url 'http://example.com'
26
27
  end
27
28
 
28
29
  run Boutique::App if !ENV['BOUTIQUE_CMD']
@@ -1,291 +1,303 @@
1
1
  require 'rubygems'
2
+ require 'bundler/setup'
2
3
  require 'sinatra/base'
3
4
  require 'dm-core'
4
5
  require 'dm-types'
5
6
  require 'dm-timestamps'
7
+ require 'dm-validations'
6
8
  require 'date'
7
9
  require 'digest/sha1'
8
10
  require 'json'
9
11
  require 'openssl'
10
12
  require 'pony'
13
+ require 'preamble'
14
+ require 'tilt'
15
+ require 'tempfile'
16
+ require 'uri'
17
+ require 'cgi'
11
18
 
12
19
  DataMapper::Model.raise_on_save_failure = true
13
20
 
14
21
  module Boutique
15
- VERSION = '0.0.9'
22
+ VERSION = '0.0.10'
16
23
 
17
24
  class << self
18
25
  def configure(setup_db=true)
19
- yield Config
20
- DataMapper.setup(:default,
21
- :adapter => config.db_adapter,
22
- :host => config.db_host,
23
- :username => config.db_username,
24
- :password => config.db_password,
25
- :database => config.db_database
26
- ) if setup_db
26
+ yield config
27
+ DataMapper.setup(:default, config.db_options) if setup_db
28
+ Pony.options = config.email_options if !config.email_options.nil?
27
29
  end
28
30
 
29
31
  def config
30
- Config
32
+ @config ||= Config.new('config')
31
33
  end
32
34
 
33
- def product(code)
34
- builder = ProductBuilder.new
35
- builder.code(code)
36
- yield builder
37
- product = Product.first_or_create({:code => code}, builder.to_hash)
38
- product.save
39
- product
35
+ def product(key)
36
+ yield Product.new(key)
40
37
  end
41
- end
42
38
 
43
- class Config
44
- def self.dev_email(value=nil)
45
- @dev_email = value if !value.nil?
46
- @dev_email
39
+ def list(key)
40
+ yield List.new(key)
47
41
  end
42
+ end
48
43
 
49
- def self.pem_cert_id(value=nil)
50
- @pem_cert_id = value if !value.nil?
51
- @pem_cert_id
52
- end
44
+ module MemoryResource
45
+ def self.included(base)
46
+ base.extend(ClassMethods)
47
+ base.attr_resource :key
48
+ base.reset_db
49
+ end
50
+
51
+ module ClassMethods
52
+ def attr_resource(*names)
53
+ names.each do |name|
54
+ define_method(name) do |*args|
55
+ value = args[0]
56
+ instance_variable_set("@#{name}".to_sym, value) if !value.nil?
57
+ instance_variable_get("@#{name}".to_sym)
58
+ end
59
+ end
60
+ end
53
61
 
54
- def self.pem_private(value=nil)
55
- @pem_private = value if !value.nil?
56
- @pem_private
57
- end
62
+ def reset_db
63
+ @db = {}
64
+ end
58
65
 
59
- def self.pem_public(value=nil)
60
- @pem_public = value if !value.nil?
61
- @pem_public
62
- end
66
+ def [](key)
67
+ @db[key]
68
+ end
63
69
 
64
- def self.pem_paypal(value=nil)
65
- @pem_paypal = value if !value.nil?
66
- @pem_paypal
67
- end
70
+ def []=(key, value)
71
+ @db[key] = value
72
+ end
68
73
 
69
- def self.download_path(value=nil)
70
- @download_path = value if !value.nil?
71
- @download_path
72
- end
74
+ def include?(key)
75
+ self.to_a.include?(key)
76
+ end
73
77
 
74
- def self.download_dir(value=nil)
75
- @download_dir = value if !value.nil?
76
- @download_dir
78
+ def to_a
79
+ @db.keys
80
+ end
77
81
  end
78
82
 
79
- def self.db_adapter(value=nil)
80
- @db_adapter = value if !value.nil?
81
- @db_adapter
83
+ def initialize(key)
84
+ @key = key
85
+ self.class[key] = self
82
86
  end
87
+ end
83
88
 
84
- def self.db_host(value=nil)
85
- @db_host = value if !value.nil?
86
- @db_host
89
+ class Emailer
90
+ def initialize(list)
91
+ @list = list
87
92
  end
88
93
 
89
- def self.db_username(value=nil)
90
- @db_username = value if !value.nil?
91
- @db_username
92
- end
94
+ def render(path, locals = {}, preamble = false)
95
+ path = full_path(path)
96
+ raise "File not found: #{path}" if !File.exist?(path)
93
97
 
94
- def self.db_password(value=nil)
95
- @db_password = value if !value.nil?
96
- @db_password
97
- end
98
+ yaml, body = Preamble.load(path)
99
+ templates_for(path).each do |template|
100
+ blk = proc { body }
101
+ body = template.new(path, &blk).render(self, locals)
102
+ end
98
103
 
99
- def self.db_database(value=nil)
100
- @db_database = value if !value.nil?
101
- @db_database
104
+ preamble ? [yaml, body] : body
105
+ end
106
+
107
+ def deliver(subscriber, path, locals = {})
108
+ locals = locals.merge(
109
+ subscribe_url: @list.subscribe_url,
110
+ confirm_url: subscriber.confirm_url,
111
+ unsubscribe_url: subscriber.unsubscribe_url)
112
+ yaml, body = self.render(path, locals, true)
113
+ if yaml['day'] == 0
114
+ ymd = Date.today.strftime("%Y-%m-%d")
115
+ Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
116
+ else
117
+ raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed?
118
+ Email.create(email_key: yaml['key'], subscriber: subscriber)
119
+ end
120
+ Pony.mail(
121
+ to: subscriber.email,
122
+ from: @list.from,
123
+ subject: yaml['subject'],
124
+ headers: {'Content-Type' => 'text/html'},
125
+ body: body)
126
+ rescue DataMapper::SaveFailureError
127
+ raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
128
+ end
129
+
130
+ def deliver_zero(subscriber)
131
+ self.deliver(subscriber, emails[0])
132
+ end
133
+
134
+ def blast(path, locals = {})
135
+ yaml, body = Preamble.load(full_path(path))
136
+ email_key = yaml['key']
137
+ @list.subscribers.all(confirmed: true).each do |subscriber|
138
+ # TODO: speed up by moving filter outside of loop
139
+ if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
140
+ self.deliver(subscriber, path, locals)
141
+ end
142
+ end
102
143
  end
103
144
 
104
- def self.pp_email(value=nil)
105
- @pp_email = value if !value.nil?
106
- @pp_email
145
+ def drip
146
+ today = Date.today
147
+ max_day = emails.keys.max || 0
148
+ subscribers = @list.subscribers.all(
149
+ :confirmed => true,
150
+ :drip_on.lt => today,
151
+ :drip_day.lt => max_day)
152
+ subscribers.each do |subscriber|
153
+ subscriber.drip_on = today
154
+ subscriber.drip_day += 1
155
+ subscriber.save
156
+ if (email_path = emails[subscriber.drip_day])
157
+ self.deliver(subscriber, email_path)
158
+ end
159
+ end
107
160
  end
108
161
 
109
- def self.pp_url(value=nil)
110
- @pp_url = value if !value.nil?
111
- @pp_url
162
+ private
163
+ def full_path(path)
164
+ File.join(@list.emails, path)
165
+ end
166
+
167
+ def templates_for(path)
168
+ basename = File.basename(path)
169
+ basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
170
+ end
171
+
172
+ def emails
173
+ @emails ||= begin
174
+ emails = {}
175
+ Dir.entries(@list.emails).each do |filename|
176
+ next if File.directory?(filename)
177
+ # TODO: stop duplicating calls to Preamble, store in memory
178
+ yaml, body = Preamble.load(full_path(filename))
179
+ if yaml && yaml['day'] && yaml['key']
180
+ emails[yaml['day']] = filename
181
+ end
182
+ end
183
+ emails
184
+ end
112
185
  end
113
186
  end
114
187
 
115
- class ProductBuilder
116
- def code(value=nil)
117
- @code = value if !value.nil?
118
- @code
119
- end
120
-
121
- def name(value=nil)
122
- @name = value if !value.nil?
123
- @name
124
- end
188
+ class Config
189
+ include MemoryResource
190
+ attr_resource :dev_email,
191
+ :stripe_api_key,
192
+ :download_dir,
193
+ :download_path,
194
+ :db_options,
195
+ :email_options
196
+ end
125
197
 
126
- def files(value=nil)
127
- @files = value if !value.nil?
128
- @files
129
- end
198
+ class Product
199
+ include MemoryResource
200
+ attr_resource :from, :files, :price
201
+ end
130
202
 
131
- def price(value=nil)
132
- @price = value if !value.nil?
133
- @price
134
- end
203
+ class List
204
+ include MemoryResource
205
+ attr_resource :from, :emails, :url
135
206
 
136
- def return_url(value=nil)
137
- @return_url = value if !value.nil?
138
- @return_url
207
+ def subscribers
208
+ Subscriber.all(list_key: self.key)
139
209
  end
140
210
 
141
- def support_email(value=nil)
142
- @support_email = value if !value.nil?
143
- @support_email
144
- end
145
-
146
- def to_hash
147
- {:code => @code,
148
- :name => @name,
149
- :files => @files,
150
- :price => @price,
151
- :return_url => @return_url,
152
- :support_email => @support_email}
211
+ def subscribe_url
212
+ url = URI.parse(self.url)
213
+ params = [url.query]
214
+ params << "boutique=subscribe/#{CGI.escape(self.key)}"
215
+ url.query = params.compact.join('&')
216
+ url.to_s
153
217
  end
154
218
  end
155
219
 
156
- class Product
157
- include DataMapper::Resource
158
-
159
- property :id, Serial
160
- property :code, String, :required => true, :unique => true
161
- property :name, String, :required => true, :unique => true
162
- property :files, CommaSeparatedList, :required => true
163
- property :price, Decimal, :required => true
164
- property :return_url, String, :required => true
165
- property :support_email, String, :required => true
166
-
167
- has n, :purchases
168
- end
169
-
170
220
  class Purchase
171
221
  include DataMapper::Resource
172
222
 
173
223
  property :id, Serial
224
+ property :product_key, String, required: true
174
225
  property :created_at, DateTime
175
- property :counter, Integer, :required => true
176
- property :secret, String, :required => true
226
+ property :counter, Integer, required: true
227
+ property :secret, String, required: true
177
228
  property :transaction_id, String
178
229
  property :email, String
179
- property :name, String
230
+ property :name, String, format: :email_address
180
231
  property :completed_at, DateTime
181
232
  property :downloads, CommaSeparatedList
233
+ end
182
234
 
183
- belongs_to :product
235
+ class Subscriber
236
+ include DataMapper::Resource
184
237
 
185
- def initialize(attr = {})
186
- attr[:counter] ||= 0
187
- attr[:secret] ||= random_hash
238
+ property :id, Serial
239
+ property :list_key, String, required: true, unique_index: :list_key_email
240
+ property :email, String, required: true, unique_index: :list_key_email, format: :email_address
241
+ property :secret, String, required: true
242
+ property :confirmed, Boolean
243
+ property :created_at, DateTime
244
+ property :drip_on, Date, required: true
245
+ property :drip_day, Integer, required: true, default: 0
246
+
247
+ validates_within :list_key, set: List
248
+ validates_uniqueness_of :email, scope: :list_key
249
+
250
+ has n, :emails
251
+
252
+ def initialize(*args)
188
253
  super
254
+ self.secret ||= Digest::SHA1.hexdigest("#{rand(1000)}-#{Time.now}")[0..6]
255
+ self.drip_on ||= Date.today
189
256
  end
190
257
 
191
- def complete(txn_id, email, name)
192
- self.transaction_id = txn_id
193
- self.email = email
194
- self.name = name
195
- self.completed_at = DateTime.now
196
- link_downloads!
258
+ def list
259
+ @list ||= List[self.list_key]
197
260
  end
198
261
 
199
- def completed?
200
- !completed_at.nil? && !transaction_id.nil?
262
+ def confirm!(secret)
263
+ self.confirmed = true if self.secret == secret
264
+ self.save
201
265
  end
202
266
 
203
- def maybe_refresh_downloads!
204
- if self.completed? &&
205
- (self.downloads.nil? ||
206
- self.downloads.any? {|d| !File.exist?(d) })
207
- self.link_downloads!
208
- self.save
209
- end
267
+ def unconfirm!(secret)
268
+ self.confirmed = false if self.secret == secret
269
+ self.save
210
270
  end
211
271
 
212
- def link_downloads!
213
- return if !completed?
214
- self.downloads = product.files.map do |file|
215
- linked_file = "/#{Date.today.strftime('%Y%m%d')}-#{random_hash}/#{File.basename(file)}"
216
- full_dir = File.dirname("#{Boutique.config.download_dir}#{linked_file}")
217
- `mkdir -p #{full_dir}`
218
- `ln -s #{file} #{Boutique.config.download_dir}#{linked_file}`
219
- "#{Boutique.config.download_path}#{linked_file}"
220
- end
221
- self.counter += 1
272
+ def confirm_url
273
+ secret_url("confirm")
222
274
  end
223
275
 
224
- def boutique_id
225
- (self.id.nil? || self.secret.nil?) ?
226
- raise('Cannot get boutique_id for unsaved purchase') :
227
- "#{self.id}-#{self.secret}"
276
+ def unsubscribe_url
277
+ secret_url("unsubscribe")
228
278
  end
229
279
 
230
- def random_hash
231
- Digest::SHA1.hexdigest("#{DateTime.now}#{rand}")[0..9]
280
+ private
281
+ def secret_url(action)
282
+ url = URI.parse(self.list.url)
283
+ params = [url.query]
284
+ params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
285
+ url.query = params.compact.join('&')
286
+ url.to_s
232
287
  end
288
+ end
233
289
 
234
- def send_mail
235
- raise 'Cannot send link to incomplete purchase' if !completed?
236
- Pony.mail(
237
- :to => self.email,
238
- :from => self.product.support_email,
239
- :subject => "#{self.product.name} Receipt",
240
- :body => "Thanks for purchasing #{self.product.name}! " +
241
- "To download it, follow this link:\n\n" +
242
- " #{self.product.return_url}?b=#{boutique_id}\n\n" +
243
- "Please reply if you have any troubles.\n"
244
- )
245
- end
290
+ class Email
291
+ include DataMapper::Resource
246
292
 
247
- def to_json
248
- {
249
- :id => id,
250
- :counter => counter,
251
- :completed => completed?,
252
- :name => product.name,
253
- :code => product.code,
254
- :downloads => downloads
255
- }.to_json
256
- end
293
+ property :id, Serial
294
+ property :email_key, String, required: true
295
+ property :created_at, DateTime
257
296
 
258
- def paypal_form(notify_url)
259
- values = {
260
- :business => Boutique.config.pp_email,
261
- :cmd => '_xclick',
262
- :item_name => product.name,
263
- :item_number => product.code,
264
- :amount => product.price.to_f,
265
- :currency_code => 'USD',
266
- :notify_url => "#{notify_url}/#{boutique_id}",
267
- :return => "#{product.return_url}?b=#{boutique_id}",
268
- :cert_id => Boutique.config.pem_cert_id
269
- }
270
- {'action' => Boutique.config.pp_url,
271
- 'cmd' => '_s-xclick',
272
- 'encrypted' => encrypt(values)}
273
- end
297
+ validates_uniqueness_of :email_key, scope: :subscriber
298
+ validates_presence_of :subscriber
274
299
 
275
- private
276
- def encrypt(values)
277
- signed = OpenSSL::PKCS7::sign(
278
- OpenSSL::X509::Certificate.new(File.read(Boutique.config.pem_public)),
279
- OpenSSL::PKey::RSA.new(File.read(Boutique.config.pem_private), ''),
280
- values.map { |k,v| "#{k.to_s}=#{v.to_s}" }.join("\n"),
281
- [],
282
- OpenSSL::PKCS7::BINARY)
283
- OpenSSL::PKCS7::encrypt(
284
- [OpenSSL::X509::Certificate.new(File.read(Boutique.config.pem_paypal))],
285
- signed.to_der,
286
- OpenSSL::Cipher::Cipher::new("DES3"),
287
- OpenSSL::PKCS7::BINARY).to_s.gsub("\n", "")
288
- end
300
+ belongs_to :subscriber
289
301
  end
290
302
 
291
303
  DataMapper.finalize
@@ -297,70 +309,44 @@ module Boutique
297
309
  error do
298
310
  Pony.mail(
299
311
  :to => Boutique.config.dev_email,
300
- :from => "boutique@#{Boutique.config.dev_email.split('@')[1..-1].join}",
301
312
  :subject => 'Boutique Error',
302
313
  :body => request.env['sinatra.error'].to_s
303
314
  ) if Boutique.config.dev_email
304
315
  end
305
316
 
306
- get '/buy/:code' do
307
- product = Boutique::Product.first(:code => params[:code])
308
- if product.nil?
309
- halt(404, "product #{params[:code]} not found")
310
- end
311
- purchase = Boutique::Purchase.new
312
- product.purchases << purchase
313
- product.save
314
- form = purchase.paypal_form("http://#{request.host}/notify")
315
- "<!doctype html><html><head><title>Redirecting to PayPal...</title>" +
316
- "<meta charset='utf-8'></head><body>" +
317
- "<form name='paypal' method='post' action='#{form['action']}'>" +
318
- "<input type='hidden' name='cmd' value='#{form['cmd']}'>" +
319
- "<input type='hidden' name='encrypted' value='#{form['encrypted']}'>" +
320
- "<input type='submit' value='submit' style='visibility:hidden'>" +
321
- "</form><script>document.paypal.submit();</script></body></html>"
317
+ post '/subscribe/:list_key' do
318
+ list = get_list(params[:list_key])
319
+ subscriber = Subscriber.first_or_create(
320
+ list_key: list.key,
321
+ email: params[:email])
322
+ Emailer.new(list).deliver_zero(subscriber) rescue nil
323
+ ''
322
324
  end
323
325
 
324
- post '/notify/:boutique_id' do
325
- purchase = get_purchase(params[:boutique_id])
326
- if !purchase.completed? &&
327
- params['txn_id'] &&
328
- params['payment_status'] &&
329
- params['first_name'] &&
330
- params['payer_email'] &&
331
- params['receiver_email'] == Boutique.config.pp_email
332
- purchase.complete(params['txn_id'], params['payer_email'], params['first_name'])
333
- purchase.send_mail
334
- purchase.save
335
- end
326
+ post '/confirm/:list_key/:id/:secret' do
327
+ list = get_list(params[:list_key])
328
+ subscriber = get_subscriber(params[:id], list, params[:secret])
329
+ subscriber.confirm!(params[:secret])
336
330
  ''
337
331
  end
338
332
 
339
- post '/recover/:code' do
340
- product = Boutique::Product.first(:code => params[:code])
341
- purchase = product.purchases.first(:email => params['email']) if product
342
- if product.nil? || purchase.nil? || !purchase.completed?
343
- halt(404, "purchase #{params[:code]}/#{params['email']} not found")
344
- end
345
- purchase.send_mail
346
- purchase.boutique_id
333
+ post '/unsubscribe/:list_key/:id/:secret' do
334
+ list = get_list(params[:list_key])
335
+ subscriber = get_subscriber(params[:id], list, params[:secret])
336
+ subscriber.unconfirm!(params[:secret])
337
+ ''
347
338
  end
348
339
 
349
- get '/record/:boutique_id' do
350
- purchase = get_purchase(params[:boutique_id])
351
- purchase.maybe_refresh_downloads!
352
- params['jsonp'].nil? ?
353
- purchase.to_json :
354
- "#{params['jsonp']}(#{purchase.to_json})"
340
+ private
341
+ def get_list(list_key)
342
+ List[list_key] || halt(404)
355
343
  end
356
344
 
357
- def get_purchase(boutique_id)
358
- id, secret = boutique_id.split('-')
359
- purchase = Boutique::Purchase.get(id)
360
- if purchase.nil? || purchase.secret != secret
361
- halt(404, "purchase #{params[:boutique_id]} not found")
362
- end
363
- purchase
345
+ def get_subscriber(id, list, secret)
346
+ Subscriber.first(
347
+ id: params[:id],
348
+ list_key: list.key,
349
+ secret: secret) || halt(404)
364
350
  end
365
351
  end
366
352
  end