boutique 0.0.11 → 0.1.0

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 60fd1d83530e2f764fa03935ded63d4eb1f776f9
4
+ data.tar.gz: 9c2561be47adbe33b57ccac1af6db4b1d37e4a29
5
+ SHA512:
6
+ metadata.gz: d5b45c086596e4ae72cc5977ce98b4db52bae8dd48d2640e57e886884601cea229adb5a4c3b9ebca5deb6edada835d261d4b7dff6cee2dc27be7d4eb0a995901
7
+ data.tar.gz: 6150e6f5678ec6003418ca1bccb8a91c17b6f62b0f2065e2e01363ff025fb772f9d4bdda066d38a6f68ed7105e9cb9cbf70b6b4c80c3513267f8e002a89b5b90
data/README.md CHANGED
@@ -1,11 +1,9 @@
1
- Description
2
- ===========
1
+ # Description
3
2
 
4
3
  Boutique is a Sinatra app for drip emails (and soon-to-be product checkouts).
5
4
  Still in development!
6
5
 
7
- Installation
8
- ============
6
+ # Installation
9
7
 
10
8
  $ gem install boutique
11
9
 
@@ -15,12 +13,12 @@ Setup a `config.ru` file and run it like any other Sinatra app:
15
13
  require 'boutique'
16
14
 
17
15
  Boutique.configure do |c|
18
- c.dev_email 'dev@mailinator.com'
16
+ c.error_email 'dev@mailinator.com'
19
17
  c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
20
18
  c.download_dir '/path/to/download'
21
19
  c.download_path '/download'
22
20
 
23
- c.db_options(adapter: 'postgresql', host: 'localhost',
21
+ c.db_options(adapter: 'postgres', host: 'localhost',
24
22
  username: 'root', password: 'secret', database: 'boutique')
25
23
  c.email_options(via: :smtp, via_options: {host: 'smtp.example.org'})
26
24
  end
@@ -41,14 +39,13 @@ Now setup the database tables (assuming you've already created the database and
41
39
  credentials) and stick the `.css` and `.js` files in your project. Note that
42
40
  `boutique.js` is dependent on jQuery.
43
41
 
44
- $ boutique --migrate
42
+ $ bin/boutique --migrate
45
43
  $ boutique --assets
46
44
  new -- boutique.js
47
45
  new -- boutique.css
48
46
  $ mv boutique.js boutique.css /path/to/project/assets/.
49
47
 
50
- Drip Emails
51
- ===========
48
+ # Drip Emails
52
49
 
53
50
  Emails can be written in any templating format that `Tilt` accepts. Stick them
54
51
  in `/path/to/emails-dir` (configured above in `config.ru`). Emails use
@@ -110,26 +107,31 @@ day by mistake. Use cron to schedule drips:
110
107
  $ crontab -e
111
108
  0 8 * * * boutique --drip
112
109
 
113
- Development
114
- ===========
110
+ # Rack Extensions
115
111
 
116
- Tests are setup to run individually via `ruby test/*_test.rb` or run them all
117
- via `rake`.
112
+ I recommend using these extensions in production:
113
+
114
+ * [Rack Timeout](https://github.com/heroku/rack-timeout)
115
+ * [Rack Throttle](https://github.com/datagraph/rack-throttle)
116
+
117
+ # Development
118
+
119
+ Run all tests with `rake`.
120
+
121
+ Run individual tests with `ruby path/to/test.rb` or `rake TEST=path/to/test.rb`.
118
122
 
119
123
  To start the server for local development:
120
124
 
121
125
  $ BOUTIQUE_DEV=1 shotgun
122
126
 
123
- TODO
124
- ====
127
+ # TODO
125
128
 
126
- * add UI for error handling and invalid emails
127
- * switch to Stripe
128
- * add customizable? email integration for purchase receipts + recover
129
+ * add Stripe integration
130
+ * add template-able email integration for purchase receipts + recover
129
131
  * add re-usable UI for purchasing, downloading, recover
132
+ * upgrade to Tilt 2.0
130
133
 
131
- License
132
- =======
134
+ # License
133
135
 
134
136
  Copyright Hugh Bien - http://hughbien.com.
135
137
  Released under BSD License, see LICENSE.md for more info.
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
- require 'optparse'
3
- require File.expand_path('lib/boutique', File.dirname(__FILE__))
4
- require 'dm-migrations'
5
2
  require 'date'
6
3
  require 'fileutils'
4
+ require 'optparse'
5
+ require_relative '../lib/boutique'
7
6
 
8
7
  ENV['BOUTIQUE_CMD'] = '1'
9
8
 
@@ -40,10 +39,7 @@ module Boutique
40
39
 
41
40
  def self.migrate
42
41
  load_config
43
- DataMapper.auto_upgrade!
44
- rescue DataObjects::SyntaxError => e
45
- DataMapper.auto_upgrade!
46
- load_config
42
+ Boutique::Migrate.run
47
43
  end
48
44
 
49
45
  private
data/config.ru CHANGED
@@ -1,18 +1,19 @@
1
+ require 'sqlite3'
1
2
  require File.expand_path('lib/boutique', File.dirname(__FILE__))
2
3
 
3
- Boutique.configure(ENV['BOUTIQUE_DEV'] || !ENV['BOUTIQUE_CMD'].nil?) do |c|
4
- #c.dev_email 'dev@localhost'
4
+ Boutique.configure do |c|
5
+ # c.error_email 'dev@localhost'
5
6
  c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
6
7
  c.download_path '/download'
7
8
  c.download_dir File.expand_path('temp', File.dirname(__FILE__))
8
9
  c.db_options(
9
- adapter: 'sqlite3',
10
+ adapter: 'sqlite',
10
11
  host: 'localhost',
11
12
  username: 'root',
12
13
  password: 'secret',
13
14
  database: 'db.sqlite3')
14
15
  c.email_options(via: :sendmail)
15
- end
16
+ end if ENV['RACK_ENV'] != 'test'
16
17
 
17
18
  Boutique.product('readme') do |p|
18
19
  p.from 'support@localhost'
@@ -1,361 +1,18 @@
1
- require 'rubygems'
2
1
  require 'bundler/setup'
3
- require 'sinatra/base'
4
- require 'dm-core'
5
- require 'dm-types'
6
- require 'dm-timestamps'
7
- require 'dm-validations'
2
+ require 'cgi'
8
3
  require 'date'
9
- require 'digest/sha1'
10
4
  require 'json'
11
5
  require 'openssl'
12
6
  require 'pony'
13
7
  require 'preamble'
14
- require 'tilt'
8
+ require 'securerandom'
9
+ require 'sequel'
10
+ require 'sinatra/base'
15
11
  require 'tempfile'
12
+ require 'tilt'
16
13
  require 'uri'
17
- require 'cgi'
18
-
19
- DataMapper::Model.raise_on_save_failure = true
20
-
21
- module Boutique
22
- VERSION = '0.0.11'
23
-
24
- class << self
25
- def configure(setup_db=true)
26
- yield config
27
- DataMapper.setup(:default, config.db_options) if setup_db
28
- Pony.options = config.email_options if !config.email_options.nil?
29
- end
30
-
31
- def config
32
- @config ||= Config.new('config')
33
- end
34
-
35
- def product(key)
36
- yield Product.new(key)
37
- end
38
-
39
- def list(key)
40
- yield List.new(key)
41
- end
42
- end
43
-
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
61
-
62
- def reset_db
63
- @db = {}
64
- end
65
-
66
- def [](key)
67
- @db[key]
68
- end
69
-
70
- def []=(key, value)
71
- @db[key] = value
72
- end
73
-
74
- def include?(key)
75
- self.to_a.include?(key)
76
- end
77
-
78
- def to_a
79
- @db.keys
80
- end
81
- end
82
-
83
- def initialize(key)
84
- @key = key
85
- self.class[key] = self
86
- end
87
- end
88
-
89
- class Emailer
90
- def initialize(list, directory = nil)
91
- @list = list
92
- @directory = directory
93
- end
94
-
95
- def render(path, locals = {}, preamble = false)
96
- path = @directory ?
97
- File.join(@directory, path) :
98
- full_path(path)
99
- raise "File not found: #{path}" if !File.exist?(path)
100
-
101
- yaml, body = preamble(path)
102
- templates_for(path).each do |template|
103
- blk = proc { body }
104
- body = template.new(path, &blk).render(self, locals)
105
- end
106
-
107
- preamble ? [yaml, body] : body
108
- end
109
-
110
- def deliver(subscriber, path, locals = {})
111
- locals = locals.merge(
112
- subscribe_url: @list.subscribe_url,
113
- confirm_url: subscriber.confirm_url,
114
- unsubscribe_url: subscriber.unsubscribe_url)
115
- yaml, body = self.render(path, locals, true)
116
- if yaml['day'] == 0
117
- ymd = Date.today.strftime("%Y-%m-%d")
118
- Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
119
- else
120
- raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed?
121
- Email.create(email_key: yaml['key'], subscriber: subscriber)
122
- end
123
- Pony.mail(
124
- to: subscriber.email,
125
- from: @list.from,
126
- subject: yaml['subject'],
127
- headers: {'Content-Type' => 'text/html'},
128
- body: body)
129
- rescue DataMapper::SaveFailureError
130
- raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
131
- end
132
-
133
- def deliver_zero(subscriber)
134
- self.deliver(subscriber, emails[0])
135
- end
136
-
137
- def blast(path, locals = {})
138
- yaml, body = preamble(full_path(path))
139
- email_key = yaml['key']
140
- @list.subscribers.all(confirmed: true).each do |subscriber|
141
- # TODO: speed up by moving filter outside of loop
142
- if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
143
- self.deliver(subscriber, path, locals)
144
- end
145
- end
146
- end
147
-
148
- def drip
149
- today = Date.today
150
- max_day = emails.keys.max || 0
151
- subscribers = @list.subscribers.all(
152
- :confirmed => true,
153
- :drip_on.lt => today,
154
- :drip_day.lt => max_day)
155
- subscribers.each do |subscriber|
156
- subscriber.drip_on = today
157
- subscriber.drip_day += 1
158
- subscriber.save
159
- if (email_path = emails[subscriber.drip_day])
160
- self.deliver(subscriber, email_path)
161
- end
162
- end
163
- end
164
-
165
- private
166
- def full_path(path)
167
- File.join(@list.emails, path)
168
- end
169
-
170
- def templates_for(path)
171
- basename = File.basename(path)
172
- basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
173
- end
174
-
175
- def emails
176
- @emails ||= begin
177
- emails = {}
178
- Dir.entries(@list.emails).each do |filename|
179
- next if File.directory?(filename)
180
- # TODO: stop duplicating calls to preamble, store in memory
181
- yaml, body = preamble(full_path(filename))
182
- if yaml && yaml['day'] && yaml['key']
183
- emails[yaml['day']] = filename
184
- end
185
- end
186
- emails
187
- end
188
- end
189
-
190
- def preamble(path)
191
- Preamble.load(path)
192
- rescue
193
- [{}, File.read(path)]
194
- end
195
- end
196
-
197
- class Config
198
- include MemoryResource
199
- attr_resource :dev_email,
200
- :stripe_api_key,
201
- :download_dir,
202
- :download_path,
203
- :db_options,
204
- :email_options
205
- end
206
-
207
- class Product
208
- include MemoryResource
209
- attr_resource :from, :files, :price
210
- end
211
-
212
- class List
213
- include MemoryResource
214
- attr_resource :from, :emails, :url
215
-
216
- def subscribers
217
- Subscriber.all(list_key: self.key)
218
- end
219
-
220
- def subscribe_url
221
- url = URI.parse(self.url)
222
- params = [url.query]
223
- params << "boutique=subscribe/#{CGI.escape(self.key)}"
224
- url.query = params.compact.join('&')
225
- url.to_s
226
- end
227
- end
228
-
229
- class Purchase
230
- include DataMapper::Resource
231
-
232
- property :id, Serial
233
- property :product_key, String, required: true
234
- property :created_at, DateTime
235
- property :counter, Integer, required: true
236
- property :secret, String, required: true
237
- property :transaction_id, String
238
- property :email, String
239
- property :name, String, format: :email_address
240
- property :completed_at, DateTime
241
- property :downloads, CommaSeparatedList
242
- end
243
-
244
- class Subscriber
245
- include DataMapper::Resource
246
-
247
- property :id, Serial
248
- property :list_key, String, required: true, unique_index: :list_key_email
249
- property :email, String, required: true, unique_index: :list_key_email, format: :email_address
250
- property :secret, String, required: true
251
- property :confirmed, Boolean
252
- property :created_at, DateTime
253
- property :drip_on, Date, required: true
254
- property :drip_day, Integer, required: true, default: 0
255
-
256
- validates_within :list_key, set: List
257
- validates_uniqueness_of :email, scope: :list_key
258
-
259
- has n, :emails
260
-
261
- def initialize(*args)
262
- super
263
- self.secret ||= Digest::SHA1.hexdigest("#{rand(1000)}-#{Time.now}")[0..6]
264
- self.drip_on ||= Date.today
265
- end
266
-
267
- def list
268
- @list ||= List[self.list_key]
269
- end
270
-
271
- def confirm!(secret)
272
- self.confirmed = true if self.secret == secret
273
- self.save
274
- end
275
-
276
- def unconfirm!(secret)
277
- self.confirmed = false if self.secret == secret
278
- self.save
279
- end
280
-
281
- def confirm_url
282
- secret_url("confirm")
283
- end
284
-
285
- def unsubscribe_url
286
- secret_url("unsubscribe")
287
- end
288
-
289
- private
290
- def secret_url(action)
291
- url = URI.parse(self.list.url)
292
- params = [url.query]
293
- params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
294
- url.query = params.compact.join('&')
295
- url.to_s
296
- end
297
- end
298
-
299
- class Email
300
- include DataMapper::Resource
301
-
302
- property :id, Serial
303
- property :email_key, String, required: true
304
- property :created_at, DateTime
305
-
306
- validates_uniqueness_of :email_key, scope: :subscriber
307
- validates_presence_of :subscriber
308
-
309
- belongs_to :subscriber
310
- end
311
-
312
- DataMapper.finalize
313
-
314
- class App < Sinatra::Base
315
- set :raise_errors, false
316
- set :show_exceptions, false
317
-
318
- error do
319
- Pony.mail(
320
- :to => Boutique.config.dev_email,
321
- :subject => 'Boutique Error',
322
- :body => request.env['sinatra.error'].to_s
323
- ) if Boutique.config.dev_email
324
- end
325
-
326
- post '/subscribe/:list_key' do
327
- list = get_list(params[:list_key])
328
- subscriber = Subscriber.first_or_create(
329
- list_key: list.key,
330
- email: params[:email])
331
- Emailer.new(list).deliver_zero(subscriber) rescue nil
332
- ''
333
- end
334
-
335
- post '/confirm/:list_key/:id/:secret' do
336
- list = get_list(params[:list_key])
337
- subscriber = get_subscriber(params[:id], list, params[:secret])
338
- subscriber.confirm!(params[:secret])
339
- ''
340
- end
341
-
342
- post '/unsubscribe/:list_key/:id/:secret' do
343
- list = get_list(params[:list_key])
344
- subscriber = get_subscriber(params[:id], list, params[:secret])
345
- subscriber.unconfirm!(params[:secret])
346
- ''
347
- end
348
-
349
- private
350
- def get_list(list_key)
351
- List[list_key] || halt(404)
352
- end
353
14
 
354
- def get_subscriber(id, list, secret)
355
- Subscriber.first(
356
- id: params[:id],
357
- list_key: list.key,
358
- secret: secret) || halt(404)
359
- end
360
- end
361
- end
15
+ require_relative 'boutique/app'
16
+ require_relative 'boutique/config'
17
+ require_relative 'boutique/emailer'
18
+ require_relative 'boutique/version'