corkboard 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.
Files changed (41) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +44 -0
  3. data/Rakefile +29 -0
  4. data/app/assets/images/corkboard/image.png +0 -0
  5. data/app/assets/javascripts/corkboard.js.erb +45 -0
  6. data/app/assets/javascripts/corkboard/app/board.js +86 -0
  7. data/app/assets/javascripts/corkboard/base.js +9 -0
  8. data/app/assets/javascripts/corkboard/lib/publisher.js +53 -0
  9. data/app/assets/javascripts/corkboard/lib/weighted_randomizer.js +41 -0
  10. data/app/assets/stylesheets/corkboard/application.css +88 -0
  11. data/app/assets/stylesheets/corkboard/responsive.css +93 -0
  12. data/app/controllers/corkboard/application_controller.rb +15 -0
  13. data/app/controllers/corkboard/authorizations_controller.rb +65 -0
  14. data/app/controllers/corkboard/board_controller.rb +10 -0
  15. data/app/controllers/corkboard/posts_controller.rb +72 -0
  16. data/app/helpers/corkboard/application_helper.rb +72 -0
  17. data/app/models/corkboard/authorization.rb +13 -0
  18. data/app/models/corkboard/post.rb +19 -0
  19. data/app/models/corkboard/subscription.rb +34 -0
  20. data/app/views/corkboard/authorizations/index.html.erb +32 -0
  21. data/app/views/corkboard/board/_filters.html.erb +15 -0
  22. data/app/views/corkboard/board/_posts.html.erb +10 -0
  23. data/app/views/corkboard/board/show.html.erb +4 -0
  24. data/app/views/layouts/corkboard/application.html.erb +14 -0
  25. data/config/routes.rb +29 -0
  26. data/db/migrate/01_create_corkboard_authorizations.rb +17 -0
  27. data/lib/corkboard.rb +191 -0
  28. data/lib/corkboard/client.rb +51 -0
  29. data/lib/corkboard/clients/instagram.rb +26 -0
  30. data/lib/corkboard/engine.rb +27 -0
  31. data/lib/corkboard/provider.rb +21 -0
  32. data/lib/corkboard/providers/instagram.rb +29 -0
  33. data/lib/corkboard/publishers/mock.rb +14 -0
  34. data/lib/corkboard/publishers/pusher.rb +31 -0
  35. data/lib/corkboard/service/config.rb +12 -0
  36. data/lib/corkboard/version.rb +3 -0
  37. data/lib/generators/corkboard/install_generator.rb +16 -0
  38. data/lib/generators/corkboard/templates/README +14 -0
  39. data/lib/generators/corkboard/templates/initializer.rb +45 -0
  40. data/lib/tasks/corkboard_tasks.rake +4 -0
  41. metadata +412 -0
@@ -0,0 +1,15 @@
1
+ module Corkboard
2
+ class ApplicationController < ActionController::Base
3
+ def auth_admin
4
+ send(Corkboard.authentication[:admin]) if Corkboard.authentication[:admin]
5
+ end
6
+
7
+ def auth_board
8
+ send(Corkboard.authentication[:board]) if Corkboard.authentication[:board]
9
+ end
10
+
11
+ def disallow!
12
+ raise Corkboard::ActionForbidden
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,65 @@
1
+ module Corkboard
2
+ class AuthorizationsController < Corkboard::ApplicationController
3
+ before_filter(:auth_admin, {
4
+ :except => [:create]
5
+ })
6
+
7
+ # GET /:mount_point/authorizations
8
+ def index
9
+ authorizations = Corkboard::Authorization.all
10
+ providers = authorizations.map(&:provider)
11
+ available = Corkboard.services.reject { |s| providers.include?(s) }
12
+
13
+ render(:index, :locals => {
14
+ :activated => authorizations,
15
+ :available => available
16
+ })
17
+ end
18
+
19
+ # GET /:mount_point/auth/:provider/callback
20
+ # POST /:mount_point/auth/:provider/callback
21
+ def create
22
+ # TODO:
23
+ # * guard based on "state" param:
24
+ # if `session['omniauth.state]`, `params[:state]` must match.
25
+
26
+ authorization = Corkboard::Authorization.create!(auth_attrs)
27
+ subscription = Corkboard::Subscription.create!(provider, authorization)
28
+
29
+ Corkboard.publish!(subscription.backlog)
30
+ redirect_to(authorizations_path)
31
+ end
32
+
33
+ # DELETE /:mount_point/auth/:provider
34
+ def destroy
35
+ # TODO: resolve the fact that there may be more than one for the same
36
+ # provider. either disallow multiple, or select the correct one.
37
+ auth = Corkboard::Authorization.find_by_provider(params[:provider])
38
+ auth.destroy if auth
39
+ Corkboard.clear_all!
40
+
41
+ redirect_to(authorizations_path)
42
+ end
43
+
44
+ private
45
+
46
+ def auth_attrs
47
+ @auth_attrs ||= {
48
+ :resource_owner => nil, # TODO
49
+ :provider => auth_params.provider,
50
+ :uid => auth_params.uid,
51
+ :token => auth_params.credentials.token,
52
+ :info => auth_params.info,
53
+ :extra => auth_params.extra
54
+ }
55
+ end
56
+
57
+ def auth_params
58
+ @auth_params ||= request.env['omniauth.auth']
59
+ end
60
+
61
+ def provider
62
+ @provider ||= params[:provider]
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,10 @@
1
+ module Corkboard
2
+ class BoardController < Corkboard::ApplicationController
3
+ before_filter(:auth_board)
4
+
5
+ # GET /:mount_point
6
+ def show
7
+ render(:show, :locals => { :posts => Corkboard::Post.recent })
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,72 @@
1
+ module Corkboard
2
+ class PostsController < Corkboard::ApplicationController
3
+ def create
4
+ send(:"create_for_#{provider}")
5
+ end
6
+
7
+ private
8
+
9
+ def provider
10
+ @provider ||= params[:provider]
11
+ end
12
+
13
+ def create_for_instagram
14
+ instagram_challenge || instagram_callback || instagram_refresh
15
+ end
16
+
17
+ def instagram_challenge
18
+ if request.get? && params['hub.challenge'].present?
19
+ render(:text => params['hub.challenge'])
20
+ return true
21
+ end
22
+
23
+ false
24
+ end
25
+
26
+ def instagram_callback
27
+ if request.post?
28
+ # TODO: cache "friends"
29
+ # TODO: move to Subscription model
30
+ client = Corkboard.client(:instagram, { :access_token => instagram_authorization.token })
31
+ friends = client.user_follows.map(&:username)
32
+
33
+ ::Instagram.process_subscription(params['_json'].to_json) do |handler|
34
+ handler.on_tag_changed do |tag, change|
35
+ # Get "my" recent feed items... includes updates from my "friends".
36
+ response = client.recent
37
+
38
+ # Filter those based on configured "interests".
39
+ relevant = response.data.select do |entry|
40
+ friendly = friends.include?(entry.user.username)
41
+ interesting = (entry.tags.map(&:intern) & Corkboard.settings(:instagram)[:interests]).present?
42
+ friendly && interesting
43
+ end
44
+
45
+ Corkboard.publish!(relevant)
46
+ end
47
+ end
48
+
49
+ head :ok and return true
50
+ end
51
+
52
+ false
53
+ end
54
+
55
+ def instagram_refresh
56
+ if params[:refresh]
57
+ client = Corkboard.client(:instagram, { :access_token => instagram_authorization.token })
58
+ response = client.recent
59
+
60
+ Corkboard.publish!(response)
61
+
62
+ head :ok and return true
63
+ end
64
+
65
+ false
66
+ end
67
+
68
+ def instagram_authorization
69
+ @instagram_authorization ||= Corkboard::Authorization.find_by_provider('instagram')
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,72 @@
1
+ require 'weighted_randomizer'
2
+
3
+ module Corkboard
4
+ module ApplicationHelper
5
+ def attributes_for(key, custom = {})
6
+ resource = custom.delete(:resource)
7
+
8
+ attrs = begin
9
+ if (framework = Corkboard.presentation[:framework]) && framework.present?
10
+ { :class => send(:"classes_for_#{framework}", key) }
11
+ else
12
+ { :class => classes_for_plain(key) }
13
+ end
14
+ end
15
+
16
+ attrs.merge!(send(:"data_for_#{key}", resource))
17
+
18
+ attrs.tap do |h|
19
+ custom.each do |name, value|
20
+ h[name] = [value, h[name]].join(' ')
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def classes_for_bootstrap(key)
28
+ @_classes_for_bootstrap ||= {
29
+ :filters_list => 'btn-group',
30
+ :filters_item => 'btn'
31
+ }
32
+
33
+ @_classes_for_bootstrap[key]
34
+ end
35
+
36
+ def classes_for_plain(key)
37
+ @_classes_for_plain ||= {
38
+ :filters_list => nil,
39
+ :filters_item => nil
40
+ }
41
+
42
+ @_classes_for_plain[key]
43
+ end
44
+
45
+ def data_for_filters_list(*)
46
+ {}
47
+ end
48
+
49
+ def data_for_filters_item(*)
50
+ {}
51
+ end
52
+
53
+ def data_for_posts_item(post)
54
+ special = (Corkboard.interests[:scope] + [:all])
55
+ tags = post[:tags].reject { |t| special.include?(t.intern) }
56
+
57
+ {
58
+ :class => ['entry', randomizer.sample].join(' '),
59
+ :data => {
60
+ :eid => post[:eid],
61
+ :tags => tags.join(' ')
62
+ }
63
+ }
64
+ end
65
+
66
+ def randomizer
67
+ @randomizer ||= begin
68
+ WeightedRandomizer.new(Corkboard.presentation[:weights])
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ class Corkboard::Authorization < ActiveRecord::Base
2
+ @table_name = :corkboard_authorizations
3
+
4
+ belongs_to :resource_owner
5
+
6
+ attr_accessible :resource_owner, :provider, :uid, :token, :info, :extra
7
+ serialize :info, Hash
8
+ serialize :extra, Hash
9
+
10
+ def provider
11
+ read_attribute(:provider).intern
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ class Corkboard::Post
2
+ class << self
3
+ def recent
4
+ posts = []
5
+ ids = Corkboard.redis.lrange("corkboard:posts", 0, 100)
6
+
7
+ if ids.present?
8
+ keys = Corkboard.redis.mget(*ids)
9
+ posts = (Corkboard.redis.mget(*keys) || []).compact
10
+
11
+ if posts.present?
12
+ posts.map! { |post| JSON.parse(post.sub(/^[0-9]+\|/, '')).with_indifferent_access }
13
+ end
14
+ end
15
+
16
+ posts
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ class Corkboard::Subscription
2
+ class << self
3
+ def create!(provider, authorization)
4
+ client = Corkboard.client(provider, { :access_token => authorization.token })
5
+ subscription = self.new(client)
6
+
7
+ # TODO: make non-specific to Instagram.
8
+ Corkboard.interests[:scope].each do |interest|
9
+ client.subscribe('tag', { :object_id => "#{interest}" })
10
+ end
11
+
12
+ subscription
13
+ end
14
+ end
15
+
16
+ attr_reader :client
17
+
18
+ def initialize(client)
19
+ @client = client
20
+ end
21
+
22
+ def backlog
23
+ # TODO: make non-specific to Instagram.
24
+ friends = client.user_follows.map(&:username)
25
+ response = client.preload
26
+
27
+ # Filter those based on configured "interests".
28
+ response.data.select do |entry|
29
+ friendly = friends.include?(entry.user.username)
30
+ interesting = (entry.tags.map(&:intern) & Corkboard.interests[:scope]).present?
31
+ friendly && interesting
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ <article data-view="corkboard/authorizations/index" class="container">
2
+ <header>
3
+ <h1>Setup</h1>
4
+ </header>
5
+
6
+ <section>
7
+ <h2>Activated Services</h2>
8
+ <ul>
9
+ <%- activated.each do |authorization| -%>
10
+ <li>
11
+ <%#= debug(authorization) %>
12
+ <p>
13
+ <%= authorization.provider %>:
14
+ <%= authorization.info.nickname %>
15
+ <%= button_to('unlink', authorization_path(authorization.provider), :method => :delete) %>
16
+ </p>
17
+ </p>
18
+ <%- end -%>
19
+ </ul>
20
+ </section>
21
+
22
+ <section>
23
+ <h2>Available Services</h2>
24
+ <ul>
25
+ <%- available.each do |provider| -%>
26
+ <li>
27
+ <%= provider %> <%= link_to('link', authorization_path(provider)) %>
28
+ </p>
29
+ <%- end -%>
30
+ </ul>
31
+ </section>
32
+ </article>
@@ -0,0 +1,15 @@
1
+ <%- filters = Corkboard.interests[:filters] %>
2
+
3
+ <nav class="corkboard filters">
4
+ <%= content_tag(:ul, attributes_for(:filters_list)) do %>
5
+ <%= content_tag(:li, attributes_for(:filters_item, :class => :active)) do %>
6
+ <a href="#all" class="all">all</a>
7
+ <%- end -%>
8
+
9
+ <%- filters.each do |filter| -%>
10
+ <%= content_tag(:li, attributes_for(:filters_item)) do %>
11
+ <a href="#<%= filter %>"><%= filter %></a>
12
+ <%- end -%>
13
+ <%- end -%>
14
+ <%- end -%>
15
+ </nav>
@@ -0,0 +1,10 @@
1
+ <ul class="corkboard posts">
2
+ <%- posts.each do |post| -%>
3
+ <%= content_tag(:li, attributes_for(:posts_item, :resource => post)) do %>
4
+ <figure>
5
+ <img src="<%= post[:images][:low_resolution][:url] %>">
6
+ <p class="caption"><%= (post[:caption] || {})['created_time'] %></p>
7
+ </figure>
8
+ <%- end -%>
9
+ <%- end -%>
10
+ </ul>
@@ -0,0 +1,4 @@
1
+ <article id="corkboard" data-view="corkboard/board/show" class="corkboard board">
2
+ <%= render('corkboard/board/filters') %>
3
+ <%= render('corkboard/board/posts', :posts => posts) %>
4
+ </article>
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Corkboard</title>
5
+ <%= stylesheet_link_tag "corkboard/application", :media => "all" %>
6
+ <%= javascript_include_tag "corkboard/application" %>
7
+ <%= csrf_meta_tags %>
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
@@ -0,0 +1,29 @@
1
+ Corkboard::Engine.routes.draw do
2
+ match "/",
3
+ :to => "board#show",
4
+ :as => :board
5
+
6
+ match "/auth",
7
+ :to => "authorizations#index",
8
+ :as => :authorizations,
9
+ :via => [:get]
10
+
11
+ # NOTE: This route entry is purely for the sake of generating the desired
12
+ # url/path helper. In fact, OmniAuth handles the actual request.
13
+ match "/auth/:action",
14
+ :to => nil,
15
+ :as => :authorization,
16
+ :via => [:get]
17
+
18
+ match "/auth/:provider/callback",
19
+ :to => "authorizations#create",
20
+ :via => [:get, :post]
21
+
22
+ match "/auth/:provider",
23
+ :to => "authorizations#destroy",
24
+ :via => [:delete]
25
+
26
+ match "/posts/:provider/callback",
27
+ :to => "posts#create",
28
+ :via => [:get, :post]
29
+ end
@@ -0,0 +1,17 @@
1
+ class CreateCorkboardAuthorizations < ActiveRecord::Migration
2
+ def change
3
+ create_table :corkboard_authorizations, :force => true do |t|
4
+ t.references :resource_owner, :polymorphic => { :default => 'User' }
5
+ t.string :provider, :null => false
6
+ t.string :uid, :null => false
7
+ t.string :token, :null => false
8
+ t.text :info
9
+ t.text :extra
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :corkboard_authorizations, [:resource_owner_type, :resource_owner_id],
14
+ :name => 'index_corkboard_authorizations_on_resource_owner'
15
+ add_index :corkboard_authorizations, :provider
16
+ end
17
+ end
@@ -0,0 +1,191 @@
1
+ require 'corkboard/engine'
2
+ require 'omniauth'
3
+ require 'redis'
4
+
5
+ module Corkboard
6
+ class ActionForbidden < StandardError ; end
7
+
8
+ autoload :Client, 'corkboard/client'
9
+ autoload :Provider, 'corkboard/provider'
10
+
11
+ module Clients
12
+ autoload :Instagram, 'corkboard/clients/instagram'
13
+ end
14
+
15
+ module Providers
16
+ autoload :Instagram, 'corkboard/providers/instagram'
17
+ end
18
+
19
+ module Publishers
20
+ autoload :Mock, 'corkboard/publishers/mock'
21
+ autoload :Pusher, 'corkboard/publishers/pusher'
22
+ end
23
+
24
+ module Service
25
+ autoload :Config, 'corkboard/service/config'
26
+ end
27
+
28
+ # The standard mechanism for configuring Corkboard. Run the following to
29
+ # generate a fresh initializer with configuration defaults and samples:
30
+ #
31
+ # rails generate corkboard:install
32
+ #
33
+ def self.setup
34
+ yield self
35
+ end
36
+
37
+ # Public Configuration
38
+ # ---------------------------------------------------------------------------
39
+
40
+ # Redis connection.
41
+ mattr_accessor :redis
42
+ @@redis = nil
43
+
44
+ # Authentication defaults.
45
+ @@authentication = {
46
+ :admin => :disallow!,
47
+ :board => nil
48
+ }
49
+
50
+ # Authentication configuration.
51
+ def self.authentication(config = nil)
52
+ if config
53
+ @@authentication.merge!(config)
54
+ end
55
+
56
+ @@authentication
57
+ end
58
+
59
+ # Presentation/view defaults.
60
+ @@presentation = {
61
+ :title => 'Corkboard',
62
+ :description => 'Corkboard',
63
+ :framework => :bootstrap,
64
+ :weights => { :s => 10, :m => 3, :l => 1 }
65
+ }
66
+
67
+ # Presentation/view configuration.
68
+ def self.presentation(config = nil)
69
+ if config
70
+ @@presentation.merge!(config)
71
+ end
72
+
73
+ @@presentation
74
+ end
75
+
76
+ # Interests defaults.
77
+ @@interests = {
78
+ :scope => [],
79
+ :filters => [],
80
+ }
81
+
82
+ # Interests configuration.
83
+ def self.interests(config = nil)
84
+ if config
85
+ @@interests.merge!(config)
86
+ end
87
+
88
+ @@interests
89
+ end
90
+
91
+ # Enable and configure a publisher.
92
+ #
93
+ # config.publisher(:pusher, {
94
+ # :client_app => 'EXAMPLE',
95
+ # :client_key => 'EXAMPLE',
96
+ # :client_secret => 'EXAMPLE'
97
+ # })
98
+ #
99
+ def self.publisher(provider, credentials = nil)
100
+ @@publisher_configs[provider] = publisher_for(provider).new(credentials)
101
+ end
102
+
103
+ # Providers for enabled publishers.
104
+ def self.publishers
105
+ publisher_configs.keys
106
+ end
107
+
108
+ # Publication provider configurations.
109
+ mattr_accessor :publisher_configs
110
+ @@publisher_configs = ActiveSupport::OrderedHash.new
111
+
112
+ # Enable and configure a service.
113
+ #
114
+ # config.service(:instagram,
115
+ # :client_key => 'EXAMPLE',
116
+ # :client_secret => 'EXAMPLE'
117
+ # })
118
+ #
119
+ def self.service(provider, *args)
120
+ config = Corkboard::Service::Config.new(provider, args)
121
+ @@service_configs[provider] = config
122
+ end
123
+
124
+ # Service provider configurations.
125
+ mattr_accessor :service_configs
126
+ @@service_configs = ActiveSupport::OrderedHash.new
127
+
128
+ # Providers for enabled services.
129
+ def self.services
130
+ service_configs.keys
131
+ end
132
+
133
+ # Publishing methods (will likely move elsewhere)
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def self.client(strategy, options = {})
137
+ provider_for(strategy).client(options)
138
+ end
139
+
140
+ def self.clear_all!
141
+ keys = Corkboard.redis.keys("corkboard:*")
142
+ Corkboard.redis.del(keys) if keys.present?
143
+ end
144
+
145
+ def self.provider_for(key)
146
+ Corkboard::Providers.const_get(camelcase(key))
147
+ end
148
+
149
+ def self.publisher_for(key)
150
+ Corkboard::Publishers.const_get(camelcase(key))
151
+ end
152
+
153
+ # TODO: make non-specific to instagram.
154
+ # TODO: post `data` as a collection (fewer http requests sent)
155
+ def self.publish!(data)
156
+ received = Time.now.utc.to_i
157
+
158
+ data.reverse.each do |item|
159
+ eid = item.id
160
+ key = "corkboard:posts:instagram:#{eid}"
161
+ entry = Hashie::Mash.new({
162
+ :eid => eid, # String
163
+ :caption => item.caption, # Mash [:text]
164
+ :images => item.images, # Mash [:low_resolution, standard_resolution, :thumbnail]
165
+ :link => item.link, # String
166
+ :location => item.location, # Mash
167
+ :tags => item.tags, # Array
168
+ :user => item.user # Mash [:id, :username]
169
+ })
170
+
171
+ if Corkboard.redis.setnx(key, "#{received}|#{entry.to_json}")
172
+ position = Corkboard.redis.incr("corkboard:counters:post")
173
+ reference = "corkboard:posts:#{position}"
174
+
175
+ Corkboard.redis.set(reference, key)
176
+ Corkboard.redis.lpush("corkboard:posts", reference)
177
+ Corkboard.redis.ltrim("corkboard:posts", 0, 1000)
178
+
179
+ publishers.each do |name|
180
+ publisher_configs[name].publish!(entry)
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ private
187
+
188
+ def self.camelcase(value)
189
+ OmniAuth::Utils.camelize(value.to_s)
190
+ end
191
+ end