boutique 0.0.11 → 0.1.0

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