boutique 0.0.9 → 0.0.10

Sign up to get free protection for your applications and to get access to all the features.
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