corkboard 0.1.0

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