sinatra_sockets 0.0.6 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4d379215d2220dba3ac97637d938c45e7695624d
4
- data.tar.gz: c1a7fc1db29382e26218b6913be59094f72e9ddb
3
+ metadata.gz: 3b8e1fe4fafda9f7d0379477ab71a09f9d5c3527
4
+ data.tar.gz: 54a509e27709689f86e114ad309f96acffafa3f8
5
5
  SHA512:
6
- metadata.gz: 7306077f348c70d269327f3bc70e5fd2b52a419ec8c0f81e9235e73257ce74d95b72f62760e7b2d1e03cfd263ace17f23572c5b45623850cd3739cfdfb40edf2
7
- data.tar.gz: a39381a435fcea01b45610b981d05ee2b527a0db1b42e7b2914e6c7bcfc6a17569a1b21b87c017ced25dba3558bf198b5e0560e3e1158c41c73272a62d94dd78
6
+ metadata.gz: 3194158b45622e04b8e988cf89f49bf72549c1db9aa32ce47a1d0e6fb7eda730d38430ab87e2e7fc927176353f9fcd7a24c0e91b77b0f6c07611e9850d100b0d
7
+ data.tar.gz: 378b5fcf85f0cf8285bffac8164c596166c51bd0f0ce99d2ef3750f40d2a2998856ac91e7a3bfc0360e047a895eea1eb75726673f9a788bc191e560e047e9f19
data/README.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## Sinatra sockets
2
+
3
+ ### about
4
+
5
+ A boilerplate including:
6
+
7
+ - Sinatra
8
+ - Github oAuth
9
+ - Faye websockets, with token (not session) based auth so the front-end can be
10
+ on a different host.
11
+ - ActiveRecord
12
+ - CRUD generator for routes
13
+ - Server-push module to alert clients of db updates.
14
+
15
+ Note that at this point this project is the _exact same_ as the server component
16
+ of [vue-sinatra-boiler](http://github.com/maxpleaner/vue-sinatra-boiler).
17
+ Originally this included a minimal front-end, but upstream development ended up
18
+ happening on an API-only variant, and that was the most full-featured version.
19
+
20
+ In the future I would like to add generator options for components that are not
21
+ necessarily dependent on one another, such as:
22
+
23
+ - whether or not to include simple (no auth) front end demo page for websockets
24
+ - whether or not to include Activerecord with crud generator and server-push module
25
+ - Whether or not to add oAuth with token-based websocket credentials
26
+
27
+ ### installing
28
+
29
+
1
30
  ```sh
2
31
  $ gem install sinatra_sockets
3
32
  $ sinatra_sockets generate .
@@ -6,34 +35,179 @@ $ sinatra_sockets generate .
6
35
  It will print the generated directory tree:
7
36
 
8
37
  ```txt
9
- Generated directory:
10
- ./server_skeleton
38
+ .
11
39
  ├── config.ru
40
+ ├── crud_generator.rb
41
+ ├── db
42
+ │   ├── migrate
43
+ │   │   └── 20170312215739_create_todos.rb
44
+ │   └── schema.rb
12
45
  ├── Gemfile
13
46
  ├── Gemfile.lock
14
- ├── lib
15
- │   ├── routes
16
- │   │   └── ws.rb
17
- │   └── routes.rb
47
+ ├── models.rb
48
+ ├── Procfile
49
+ ├── Rakefile
18
50
  ├── README.md
19
- └── server.rb
51
+ ├── server_push.rb
52
+ ├── server.rb
53
+ └── ws.rb
20
54
 
21
- 2 directories, 7 files
55
+ 2 directories, 13 files
22
56
  ```
23
57
 
24
- Start the server with thin:
58
+ Go to Github settings and create an oAuth application
59
+
60
+ Set up the server:
25
61
 
26
62
  ```sh
27
63
  cd server_skeleton
28
- bundle exec thin start
29
- # ... listening on localhost:3000
64
+ bundle install
65
+ bundle exec rake db:create db:migrate
66
+ cp .env.example .env
67
+ nano .env # add the github credentials here
68
+ ```
69
+
70
+ Start the server with thin (port defaults to 3000):
71
+
72
+ ```sh
73
+ bundle exec thin start -p 3000
30
74
  ```
31
75
 
32
- Earlier iterations of this had more files, mainly because of front-end stuff. However, I think a boiler is best
33
- if it's kept minimal, so I removed all that and now it's API only. At the same time, though, I added a token-based
34
- credential system as well as Github oAuth. Now it can tie in well with a front-end that's hosted on another server like
35
- Webpack.
76
+ ### components in detail:
77
+
78
+ **database / models**
79
+
80
+ this uses `sinatra-activerecord` and is similar to what's found in rails.
81
+
82
+ To generate a migration: `bundle exec rake db:create_migration NAME=my_migration_name`
83
+
84
+ Then customize it and `bundle exec rake db:migrate`.
85
+
86
+ `server.rb` contains the database configuration inline in the ruby code.
87
+
88
+ Models are all listed in `models.rb`, but it isn't necessary to do so. There are
89
+ no dynamic requires happening in this application - everything is individually
90
+ required in `server.rb`.
91
+
92
+ If using the crud generator or server-push, there is one required method that all
93
+ models must respond to: `public_attributes`. It returns a hash which is a filtered
94
+ version of `attributes` (suitable to be sent to clients).
95
+
96
+ **auth**
97
+
98
+ Here's how the auth flow works (the pieces are in `server.rb` and `ws.rb`):
99
+
100
+ -
101
+ - Client checks their cookies to see if there's a token.
102
+ - If there's not, one is requested from the server and saved as a cookie.
103
+ - Client establishes a websocket connection with server, passing token in query params
104
+ -
105
+ - If the client found the token in the query, it will send a websocket request to try and authenticate.
106
+ - If the server had stored credentials for that token, the client get a success response over websockets.
107
+ - otherwise the client will get no response
108
+ - If the client had to fetch a new token, the authentication is put on hold until the user clicks the
109
+ "authenticate with github" button, passing the token to the server
110
+ - The server makes the client through the Github auth flow but stores the token ref.
111
+ - When Github auth is done clients get a ws message (looked up by token) saying they're authenticated.
112
+
113
+
114
+ **crud generator**
115
+
116
+ In the Sinatra server definition in `server.rb`, it's included with
117
+ `register Sinatra::CrudGenerator`. This exposes one behemoth of a method:
118
+ `crud_generate`.
119
+
120
+ Here's its annotated signature (all the options are keyword args and are optional unless stated)
121
+
122
+ ```rb
123
+ def crud_generate(
124
+
125
+ # String such as "todo" (required).
126
+ # The singular and pluralized forms are used to define routes i.e. /todos or /todo
127
+ resource:,
128
+
129
+ # Class such as Todo (required)
130
+ resource_class:,
131
+
132
+ # String such as '/api/', prefixed on all routes (defaults to '/')
133
+ root_path: '/',
134
+
135
+ # Hash. If this is defined, the `cross_origin` method is invoked with it as an arument.
136
+ # e.g. { cross_origin: "http://localhost:8080" }
137
+ # would allow all these CRUD routes to be hit by this origin.
138
+ cross_origin_opts: nil,
139
+
140
+ # A Proc which is passed the request and returns something truthy if the auth failed.
141
+ # For example if it returns { error: "bad reqeust" }.to_json that will be the
142
+ # final return value of the route and it will return early.
143
+ # If provided, this proc will apply to all of the routes, though each route can have
144
+ # it's own proc which will override it (see below)
145
+ auth: nil,
146
+
147
+ # Each route has its own option which is a hash for configuration.
148
+ # These are optional and defaults are set (see crud_generator.rb).
149
+ # There are slight differences in the configuration hashes accepted per route,
150
+ # but here is the full list of keys (all are optional):
151
+ #
152
+ # method: Symbol (i.e. :get or :post, must be supported by Sinatra)
153
+ #
154
+ # path: String (defaults to "/#{resource}" or "/#{plural_resource}"
155
+ #
156
+ # auth: Proc with the same signature as the one discussed before
157
+ #
158
+ # filter: Proc which is used on index route. Is passed an ActiveRecord Query of all records
159
+ # of the record_class and returns some subset which gets sent to the client.
160
+ #
161
+ # secure_params: Proc used on create/update which is passed the request object and returns
162
+ # a list of the accepted param keys (params are filtered to only include these)
163
+ #
164
+
165
+ # Hash with :method, :path, :auth, and :filter keys
166
+ index: nil,
167
+
168
+ # Hash with :method, :path, :auth, and :secure_params keys
169
+ create: nil,
170
+
171
+ # Hash with :method, :path, and :auth keys
172
+ read: nil,
173
+
174
+ # hash with :method, :path, :auth, and :secure_params keys
175
+ update: nil,
176
+
177
+ # hash with :method, :path, and :auth keys
178
+ destroy: nil,
179
+
180
+ # Array of symbols such as [:create, :update], will skip generating these routes.
181
+ except: []
182
+ )
183
+ ```
184
+
185
+ **Server push**
186
+
187
+ This is a module which can be `included` into any model.
188
+
189
+ It patches three foundational methods in ActiveRecord:
190
+
191
+ - `save` handles the case of creating new records.
192
+ - although this method is called under the hood by both `create` and `update`,
193
+ only the `create` case is handled here because there's a check whether the record is
194
+ persisted and previously unsaved.
195
+ - `update` - handles when existing records are changed
196
+ - `destroy` - handles when records are deleted.
197
+
198
+ So after `include ServerPush` is placed in the model, any calls to `create`
199
+ (or `save`) on an unsaved record will trigger a "add_record" ws message to be sent.
200
+ Any call to `destroy` will trigger "destroy_record", and similarly `update` triggers
201
+ "update_record"
202
+
203
+ By default all sockets get these messages sent to them, though this can be configured
204
+ by defining a `publish_to` method in the model that returns a list of sockets.
205
+
206
+ All of the outgoing websocket messages in server-push are hashes with this signature:
36
207
 
37
- This can be seen in action in another boilerplate, [vue-webpack-coffee-slim-boiler](https://github.com/maxpleaner/vue-webpack-coffee-slim-boiler). sinatra_sockets is used as the server component there.
208
+ - action: string ("add_record", "update_record", or "destroy_record")
209
+ - type: string i.e. "todo"
210
+ - record: Hash with the model's `public_attributes`.
38
211
 
39
- The source code includes comments.
212
+ [vue-sinatra-boiler](http://github.com/maxpleaner/vue-sinatra-boiler) has the front-end
213
+ API in place to use this.
@@ -8,3 +8,12 @@ gem 'sinatra_auth_github'
8
8
  gem 'dotenv'
9
9
  gem 'sinatra-cross_origin'
10
10
  gem "rack-proxy"
11
+ gem "sinatra-activerecord"
12
+ group :development do
13
+ gem "sqlite3"
14
+ end
15
+ group :production do
16
+ gem 'pg'
17
+ end
18
+ gem "rake"
19
+ gem 'activesupport'
@@ -1,6 +1,12 @@
1
1
  GEM
2
2
  remote: http://rubygems.org/
3
3
  specs:
4
+ activemodel (5.0.1)
5
+ activesupport (= 5.0.1)
6
+ activerecord (5.0.1)
7
+ activemodel (= 5.0.1)
8
+ activesupport (= 5.0.1)
9
+ arel (~> 7.0)
4
10
  activesupport (5.0.1)
5
11
  concurrent-ruby (~> 1.0, >= 1.0.2)
6
12
  i18n (~> 0.7)
@@ -8,6 +14,7 @@ GEM
8
14
  tzinfo (~> 1.1)
9
15
  addressable (2.5.0)
10
16
  public_suffix (~> 2.0, >= 2.0.2)
17
+ arel (7.1.4)
11
18
  byebug (9.0.6)
12
19
  concurrent-ruby (1.0.4)
13
20
  daemons (1.2.4)
@@ -23,12 +30,14 @@ GEM
23
30
  multipart-post (2.0.0)
24
31
  octokit (4.6.2)
25
32
  sawyer (~> 0.8.0, >= 0.5.3)
33
+ pg (0.19.0)
26
34
  public_suffix (2.0.5)
27
35
  rack (1.6.5)
28
36
  rack-protection (1.5.3)
29
37
  rack
30
38
  rack-proxy (0.6.0)
31
39
  rack
40
+ rake (12.0.0)
32
41
  sawyer (0.8.1)
33
42
  addressable (>= 2.3.5, < 2.6)
34
43
  faraday (~> 0.8, < 1.0)
@@ -36,10 +45,14 @@ GEM
36
45
  rack (~> 1.5)
37
46
  rack-protection (~> 1.4)
38
47
  tilt (>= 1.3, < 3)
48
+ sinatra-activerecord (2.0.11)
49
+ activerecord (>= 3.2)
50
+ sinatra (~> 1.0)
39
51
  sinatra-cross_origin (0.4.0)
40
52
  sinatra_auth_github (1.2.0)
41
53
  sinatra (~> 1.0)
42
54
  warden-github (~> 1.2.0)
55
+ sqlite3 (1.3.10)
43
56
  thin (1.7.0)
44
57
  daemons (~> 1.0, >= 1.0.9)
45
58
  eventmachine (~> 1.0, >= 1.0.4)
@@ -62,13 +75,18 @@ PLATFORMS
62
75
  ruby
63
76
 
64
77
  DEPENDENCIES
78
+ activesupport
65
79
  byebug
66
80
  dotenv
67
81
  faye-websocket
82
+ pg
68
83
  rack-proxy
84
+ rake
69
85
  sinatra
86
+ sinatra-activerecord
70
87
  sinatra-cross_origin
71
88
  sinatra_auth_github
89
+ sqlite3
72
90
  thin
73
91
 
74
92
  BUNDLED WITH
@@ -0,0 +1,2 @@
1
+ web: env RACK_ENV=production bundle exec thin start -p $PORT
2
+
@@ -1,24 +1 @@
1
-
2
- ---
3
-
4
- This server is adapted from
5
- [sinatra_sockets](http://github.com/maxpleaner/sinatra_sockets),
6
- another boiler I made.
7
-
8
- ---
9
-
10
- Steps:
11
-
12
- 1. bundle
13
- 2. thin start
14
-
15
- ---
16
-
17
- This version has a lot of stuff removed since it's serving no front-end.
18
- It's just the API for the webpack client
19
-
20
- Important files:
21
- - websocket stuff in lib/routes/ws.rb
22
- - regular routes in server.rb
23
-
24
- ---
1
+ See the readme at [sinatra_sockets](http://github.com/maxpleaner/sinatra_sockets)
@@ -0,0 +1,9 @@
1
+ # Loads the tasks from sinatra-activerecord
2
+
3
+ require "sinatra/activerecord/rake"
4
+
5
+ namespace :db do
6
+ task :load_config do
7
+ require './server'
8
+ end
9
+ end
@@ -0,0 +1,191 @@
1
+ #
2
+ # Defines the "crud_generate" method to create routes
3
+ # Load this using "register Sinatra::CrudGenerator"
4
+ #
5
+ # Note that if the :cross_origin_opts key in the crud_generate options is set,
6
+ # then sinatra-cross_origin is a dependency and "register Sinatra::CrossOrigin"
7
+ # should be run first
8
+ #
9
+
10
+ module Sinatra
11
+
12
+ module CrudGenerator
13
+
14
+ # private
15
+ def get_default_secure_params(resource_class)
16
+ Proc.new do |request|
17
+ resource_class.new.attributes.keys.reject do |key|
18
+ key.in? %w{id created_at updated_at}
19
+ end
20
+ end
21
+ end
22
+
23
+ # public
24
+ def crud_generate(
25
+ resource:, resource_class:, root_path: '/',
26
+ cross_origin_opts: nil, auth: nil,
27
+ index: nil, create: nil, read: nil, update: nil, destroy: nil,
28
+ except: []
29
+ )
30
+
31
+ # the auth argument is a proc that can be used to disallow some requests.
32
+ # It is passed the request as an argument.
33
+ # It it returns something truthy, then the result of the auth block is sent
34
+ # to the client and the route goes no further.
35
+ # That's why the default proc returns false (to allow all requests)
36
+
37
+ # The secure params (for update and create) defaults to all the keys except
38
+ # id, created_at, and updated_at
39
+ # It can be specified for a particular route
40
+ # e.g. to allow no params on create:
41
+ # crud_generate(
42
+ # ... other opts (resource and resource_class are mandatory)
43
+ # create: { secure_params: Proc.new { |request| [] } }
44
+ # )
45
+
46
+ if cross_origin_opts
47
+ # Allow CORS preflight requests.
48
+ # Each individual route still needs to state a CORS policy if it has one.
49
+ before do
50
+ if request.request_method == 'OPTIONS'
51
+ response.headers["Access-Control-Allow-Origin"] = CLIENT_BASE_URL
52
+ response.headers["Access-Control-Allow-Methods"] = "POST,DELETE,PUT,GET"
53
+ halt 200
54
+ end
55
+ end
56
+ end
57
+
58
+ plural = resource.pluralize
59
+ raise(
60
+ ArgumentError, "resource does not have a simple plural"
61
+ ) unless plural.eql?(resource + "s")
62
+
63
+ default_secure_params_proc = get_default_secure_params(resource_class)
64
+
65
+ index ||= {}
66
+ index = {
67
+ method: :get,
68
+ path: "/#{plural}",
69
+ auth: auth || Proc.new { |request| false },
70
+ filter: Proc.new { |records| records }
71
+ }.merge(index)
72
+
73
+ create ||= {}
74
+ create = {
75
+ method: :post,
76
+ path: "/#{plural}",
77
+ auth: auth || Proc.new { |request| false },
78
+ secure_params: default_secure_params_proc
79
+ }.merge(create)
80
+
81
+ read ||= {}
82
+ read = {
83
+ method: :get,
84
+ path: "/#{resource}",
85
+ auth: auth || Proc.new { |request| false }
86
+ }.merge(read)
87
+
88
+ update ||= {}
89
+ update = {
90
+ method: :put,
91
+ path: "/#{resource}",
92
+ auth: auth || Proc.new { |request| false },
93
+ secure_params: default_secure_params_proc
94
+ }.merge(update)
95
+
96
+ destroy ||= {}
97
+ destroy = {
98
+ method: :delete,
99
+ path: "/#{resource}",
100
+ auth: auth || Proc.new { |request| false }
101
+ }.merge(destroy)
102
+
103
+ unless except.include?(:index)
104
+ send(index[:method], index[:path]) do
105
+ cross_origin(cross_origin_opts) if cross_origin_opts
106
+ auth_result = index[:auth].call(request)
107
+ return auth_result if auth_result
108
+ {
109
+ success: index[:filter].call(resource_class.all).map(&:public_attributes)
110
+ }.to_json
111
+ end
112
+ end
113
+
114
+ # Calls ActiveRecord "save" (via "create"), which is patched by ServerPush
115
+ unless except.include?(:create)
116
+ send(create[:method], create[:path]) do
117
+ cross_origin(cross_origin_opts) if cross_origin_opts
118
+ auth_result = create[:auth].call(request)
119
+ return auth_result if auth_result
120
+ filtered_params = params.select do |key, val|
121
+ key.in? *create[:secure_params].call(request)
122
+ end
123
+ created = resource_class.create(filtered_params)
124
+ if created.persisted?
125
+ { success: created.public_attributes }.to_json
126
+ else
127
+ { error: created.errors.full_messages }.to_json
128
+ end
129
+ end
130
+ end
131
+
132
+ unless except.include?(:read)
133
+ send(read[:method], read[:path]) do
134
+ cross_origin(cross_origin_opts) if cross_origin_opts
135
+ auth_result = read[:auth].call(request)
136
+ return auth_result if auth_result
137
+ found = resource_class.find_by(id: params[:id])
138
+ if found
139
+ { success: found.attributes }.to_json
140
+ else
141
+ { error: ["not found"] }.to_json
142
+ end
143
+ end
144
+ end
145
+
146
+ # calls ActiveRecord "update", which is patched by ServerPush
147
+ unless except.include?(:update)
148
+ send(update[:method], update[:path]) do
149
+ cross_origin(cross_origin_opts) if cross_origin_opts
150
+ auth_result = update[:auth].call(request)
151
+ return auth_result if auth_result
152
+ filtered_params = params.select do |key, val|
153
+ key.in? *create[:secure_params].call(request)
154
+ end
155
+ found = resource_class.find_by(id: params[:id])
156
+ if found
157
+ update[:secure_params].call.each do |key|
158
+ found.send(:"#{key}=", params[key])
159
+ end
160
+ if found.valid?
161
+ found.update({})
162
+ { success: found.public_attributes }.to_json
163
+ else
164
+ { error: found.errors.full_messages }.to_json
165
+ end
166
+ else
167
+ { error: ["not found"] }.to_json
168
+ end
169
+ end
170
+ end
171
+
172
+ # calls ActiveRecord "destroy" which is patched by ServerPush
173
+ unless except.include?(:destroy)
174
+ send(destroy[:method], destroy[:path]) do
175
+ cross_origin(cross_origin_opts) if cross_origin_opts
176
+ auth_result = destroy[:auth].call(request)
177
+ return auth_result if auth_result
178
+ found = resource_class.find_by(id: params[:id])
179
+ if found
180
+ found.destroy!
181
+ { success: found.attributes }.to_json
182
+ else
183
+ { error: ["not found"] }.to_json
184
+ end
185
+ end
186
+ end
187
+
188
+ end # /crud_generate
189
+
190
+ end
191
+ end
@@ -0,0 +1,8 @@
1
+ class CreateTodos < ActiveRecord::Migration[5.0]
2
+ def change
3
+ create_table :todos do |t|
4
+ t.string :text
5
+ t.timestamps
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # Note that this schema.rb definition is the authoritative source for your
6
+ # database schema. If you need to create the application database on another
7
+ # system, you should be using db:schema:load, not running all the migrations
8
+ # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
+ # you'll amass, the slower it'll run and the greater likelihood for issues).
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema.define(version: 20170312215739) do
14
+
15
+ create_table "todos", force: :cascade do |t|
16
+ t.string "text"
17
+ t.datetime "created_at", null: false
18
+ t.datetime "updated_at", null: false
19
+ end
20
+
21
+ end
@@ -0,0 +1,14 @@
1
+ class Todo < ActiveRecord::Base
2
+ include ServerPush
3
+
4
+ def save(*args)
5
+ self.text ||= ""
6
+ super(*args)
7
+ end
8
+
9
+ # Every model should have this defined
10
+ def public_attributes
11
+ attributes
12
+ end
13
+
14
+ end
@@ -1,71 +1,54 @@
1
- # ================================================
2
- # Entry to the Sinatra server
3
- # ------------------------------------------------
4
- # This file should not be run directly
5
- # Run it with "thin start"
6
- # ================================================
7
-
8
1
  require 'sinatra/base'
2
+ require "sinatra/activerecord"
9
3
  require 'faye/websocket'
10
4
  require 'byebug'
11
5
  require 'sinatra_auth_github'
12
6
  require('dotenv'); Dotenv.load
13
7
  require 'sinatra/cross_origin'
8
+ require 'active_support/all'
14
9
 
15
- # Requires all ruby files in this directory.
16
- # Orders them by the count of "/" in their filename.
17
- # Therefore, shallower files are loaded first.
18
- # The reasoning for this is to support the common convention of naming
19
- # files according to their contained class hierarchies.
20
- # i.e. class Foo would be in foo.rb,
21
- # class Foo::Bar would be in foo/bar.rb,
22
- #
23
- # If there is the situation where a class depends on another that is in a
24
- # deeper-nested file, there's always the option to pass the dependency at
25
- # runtime.
26
-
27
- Dir.glob("./**/*.rb").sort_by { |x| x.count("/") }.each do |path|
28
- require path
29
- end
10
+ require './crud_generator'
11
+ require './server_push'
12
+ require './models'
13
+ require './ws'
30
14
 
31
- # The REST routes (listed in this file) cannot store information in the session
32
- # since it's on another host.
33
- # Rather, they pass back and forth a token identifier
34
- #
35
- # This is the :token param, and is required on all routes except for
36
- # /token
37
- #
38
- # Here's an outline of the flow:
39
- #
40
- # 1. Client hits GET /token, gets a new token
41
- # 2. Client sends token with websocket connection request at GET /ws
42
- # 3. Client hits GET /authenticate and goes through Github oAuth login
43
- # 4. Github sends callback to server, which sends the OK to client over websocket
44
-
45
- # In leue of sessions, three global objects are used:
46
- # Users: <Hash> with keys: <username> and vals: <Set(token)>
47
- # AuthenticatedTokens: <hash> with keys: <token> and vals: <username>
48
- # Sockets: <hash> with keys: <token> and vals: <socket>
49
-
50
- Sockets = {}
51
- AuthenticatedTokens = {}
52
15
  Users = Hash.new { |hash, key| hash[key] = Set.new }
16
+ AuthenticatedTokens = {}
17
+ Sockets = Hash.new { |hash, key| hash[key] = Set.new }
18
+
19
+ CLIENT_BASE_URL = if ENV["RACK_ENV"] == "production"
20
+ "https://maxpleaner.github.io"
21
+ else
22
+ "http://localhost:8080"
23
+ end
53
24
 
54
25
  class Server < Sinatra::Base
55
26
 
27
+ register Sinatra::ActiveRecordExtension
28
+
29
+ # Database config
30
+ if ENV["RACK_ENV"] == "production"
31
+ configure :production do
32
+ db = URI.parse(ENV['DATABASE_URL'] || 'postgres:///localhost/mydb')
33
+ ActiveRecord::Base.establish_connection(
34
+ :adapter => db.scheme == 'postgres' ? 'postgresql' : db.scheme,
35
+ :host => db.host,
36
+ :username => db.user,
37
+ :password => db.password,
38
+ :database => db.path[1..-1],
39
+ :encoding => 'utf8'
40
+ )
41
+ end
42
+ else
43
+ set :database, {adapter: "sqlite3", database: "db.sqlite3"}
44
+ set :show_exceptions, true
45
+ end
46
+
56
47
  set :server, 'thin'
57
48
  Faye::WebSocket.load_adapter('thin')
58
49
 
59
- # Allow some routes to be accessed from different origins
60
- # This is unnecessary for websocket requests, since browsers don't implement
61
- # the same restictions.
62
-
63
50
  register Sinatra::CrossOrigin
64
51
 
65
- # Github oAuth setup
66
- # session is needed for the Github oAuth gem
67
- # but its not used elsewhere
68
-
69
52
  enable :sessions
70
53
  set :github_options, {
71
54
  scopes: "user",
@@ -74,49 +57,68 @@ class Server < Sinatra::Base
74
57
  }
75
58
  register Sinatra::Auth::Github
76
59
 
77
- # ------------------------------------------------
78
- # Standard HTTP routes
79
- # (get '/ws' is the entrance to the websocket API)
80
- # ------------------------------------------------
81
60
 
82
- # First clients request a token
61
+ logged_in_only = Proc.new do |request|
62
+ if AuthenticatedTokens[request.params['token']]
63
+ false
64
+ else
65
+ { error: ["not_authenticated for #{request.request_method} #{request.path_info}"] }.to_json
66
+ end
67
+ end
68
+
69
+ register Sinatra::CrudGenerator
70
+ crud_generate(
71
+ resource: "todo",
72
+ resource_class: Todo,
73
+ cross_origin_opts: {
74
+ allow_origin: CLIENT_BASE_URL
75
+ },
76
+ create: { auth: logged_in_only },
77
+ update: { auth: logged_in_only },
78
+ destroy: { auth: logged_in_only }
79
+ )
80
+
81
+ get '/health' do
82
+ cross_origin allow_origin: CLIENT_BASE_URL
83
+ status 200
84
+ end
83
85
 
84
86
  get '/token' do
85
- cross_origin allow_origin: "http://localhost:8080"
87
+ cross_origin allow_origin: CLIENT_BASE_URL
86
88
  { token: new_token }.to_json
87
89
  end
88
90
 
89
- # Then they send it in websocket connection request
90
- # See server/lib/routes/ws.rb
91
-
92
91
  get '/ws' do
93
- Routes::Ws.run(request)
92
+ Ws.run(request)
94
93
  end
95
94
 
96
- # Then they authenticate with Github
97
- #
98
- # If the client refreshes the page after logging in, they should have stored
99
- # the token in a cookie. If they hit this route with the same token, it
100
- # keeps them logged in.
101
- #
102
- # This needs to be clicked like a regular link - no AJAX
103
- #
104
95
  # TODO render a proper HTML page after authenticating not just plaintext
105
96
  # saying they can close the window.
106
-
107
97
  get '/authenticate' do
108
- if token = params["token"]
109
- if socket = Sockets[token]
110
- unless username = AuthenticatedTokens[token]
98
+ username = nil
99
+ token = params["token"]
100
+ if token
101
+ sockets = Sockets[token]
102
+ if sockets.any?
103
+ username = AuthenticatedTokens[token]
104
+ unless username
111
105
  authenticate!
112
106
  username = get_username
113
- Users[username] << (token)
114
107
  AuthenticatedTokens[token] = username
115
108
  end
116
- socket.send({
117
- action: "logged_in",
118
- username: username
119
- }.to_json)
109
+
110
+ new_token = SecureRandom.urlsafe_base64
111
+ Sockets[new_token] = Sockets.delete(token)
112
+ AuthenticatedTokens[new_token] = AuthenticatedTokens.delete(token)
113
+ Users[username] << new_token
114
+
115
+ sockets.each do |socket|
116
+ socket.send({
117
+ action: "logged_in",
118
+ username: username,
119
+ new_token: new_token
120
+ }.to_json)
121
+ end
120
122
  "authenticated as #{username}. (this window can be closed)"
121
123
  else
122
124
  "error. lost your websocket connection (this window can be closed)"
@@ -126,26 +128,22 @@ class Server < Sinatra::Base
126
128
  end
127
129
  end
128
130
 
129
- # This is hit over AJAX
130
- # This closes the websocket connection
131
- # Clients should request a new token and reconnect to ws after logging out
132
-
133
131
  get '/logout' do
134
- cross_origin allow_origin: "http://localhost:8080"
132
+ cross_origin allow_origin: CLIENT_BASE_URL
135
133
  token = params[:token]
136
134
  if token
137
135
  if username = AuthenticatedTokens[token]
138
136
  logout!
139
137
  AuthenticatedTokens.delete token
140
138
  Users[username].delete token
141
- Sockets[token].close
139
+ Sockets[token].each { |ws| ws.close(1000, "logged_out") }
142
140
  Sockets.delete token
143
141
  { success: "logged out" }.to_json
144
142
  else
145
- { error: "can't find user to log out" }.to_json
143
+ { error: ["can't find user to log out"] }.to_json
146
144
  end
147
145
  else
148
- { error: 'cant log out; no token provided' }.to_json
146
+ { error: ['cant log out; no token provided'] }.to_json
149
147
  end
150
148
  end
151
149
 
@@ -0,0 +1,53 @@
1
+ # included into ActiveRecord models
2
+ module ServerPush
3
+
4
+ # Can be overridden in model to limit who gets updates
5
+ # Returns a list of sockets (by default all of them)
6
+ def publish_to
7
+ Sockets.values.map(&:to_a).flatten
8
+ end
9
+
10
+ def save(*args)
11
+ should_push = valid? && !persisted?
12
+ result = super(*args)
13
+ if should_push
14
+ publish_to.each do |socket|
15
+ socket.send({
16
+ action: "add_record",
17
+ type: self.class.to_s.underscore,
18
+ record: public_attributes
19
+ }.to_json)
20
+ end
21
+ end
22
+ result
23
+ end
24
+
25
+ def update(*args)
26
+ result = super(*args)
27
+ if result
28
+ publish_to.each do |socket|
29
+ socket.send({
30
+ action: "update_record",
31
+ type: self.class.to_s.underscore,
32
+ record: public_attributes
33
+ }.to_json)
34
+ end
35
+ end
36
+ result
37
+ end
38
+
39
+ def destroy(*args)
40
+ result = super(*args)
41
+ unless persisted?
42
+ publish_to.each do |socket|
43
+ socket.send({
44
+ action: "destroy_record",
45
+ type: self.class.to_s.underscore,
46
+ record: public_attributes
47
+ }.to_json)
48
+ end
49
+ end
50
+ result
51
+ end
52
+
53
+ end
@@ -1,6 +1,5 @@
1
- class Routes::Ws
1
+ class Ws
2
2
 
3
- # Opens a new websocket connection from a request
4
3
  def self.run(request)
5
4
  return unless Faye::WebSocket.websocket?(request.env)
6
5
  socket = Faye::WebSocket.new(request.env)
@@ -13,7 +12,7 @@ class Routes::Ws
13
12
  def self.onopen(request, ws)
14
13
  token = request.params["token"]
15
14
  if token
16
- Sockets[token] = ws
15
+ Sockets[token] << ws
17
16
  else
18
17
  ws.close
19
18
  end
@@ -21,19 +20,26 @@ class Routes::Ws
21
20
 
22
21
  def self.onmessage(request, ws, msg_data)
23
22
  data = JSON.parse msg_data
24
- if data["action"] == "try_authenticate"
25
- try_authenticate(ws, data["token"])
23
+ token = data["token"]
24
+ user = find_username(token)
25
+ case data["action"]
26
+ when "try_authenticate" then try_authenticate(ws, token)
26
27
  end
27
28
  end
28
29
 
29
30
  def self.onclose(request, ws)
30
- delete_socket(request, ws)
31
+ token = CGI.parse(URI.parse(ws.url).to_s)["token"]
32
+ Sockets.delete token
31
33
  end
32
34
 
33
35
  class << self
34
36
 
35
37
  private
36
38
 
39
+ def find_username(token)
40
+ AuthenticatedTokens[token]
41
+ end
42
+
37
43
  def try_authenticate(ws, token)
38
44
  if username = AuthenticatedTokens[token]
39
45
  ws.send({
@@ -47,11 +53,6 @@ class Routes::Ws
47
53
  EM.next_tick { ws.send msg.to_json }
48
54
  end
49
55
 
50
- def delete_socket(request, ws)
51
- token = CGI.parse(URI.parse(ws.url).to_s)["token"]
52
- Sockets.delete token
53
- end
54
-
55
56
  end
56
57
 
57
58
  end
@@ -1,3 +1,3 @@
1
1
  module SinatraSockets
2
- VERSION = '0.0.6'
2
+ VERSION = '0.0.7'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra_sockets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - maxpleaner
@@ -35,11 +35,17 @@ files:
35
35
  - bin/sinatra_sockets
36
36
  - lib/server_skeleton/Gemfile
37
37
  - lib/server_skeleton/Gemfile.lock
38
+ - lib/server_skeleton/Procfile
38
39
  - lib/server_skeleton/README.md
40
+ - lib/server_skeleton/Rakefile
39
41
  - lib/server_skeleton/config.ru
40
- - lib/server_skeleton/lib/routes.rb
41
- - lib/server_skeleton/lib/routes/ws.rb
42
+ - lib/server_skeleton/crud_generator.rb
43
+ - lib/server_skeleton/db/migrate/20170312215739_create_todos.rb
44
+ - lib/server_skeleton/db/schema.rb
45
+ - lib/server_skeleton/models.rb
42
46
  - lib/server_skeleton/server.rb
47
+ - lib/server_skeleton/server_push.rb
48
+ - lib/server_skeleton/ws.rb
43
49
  - lib/sinatra_sockets.rb
44
50
  - lib/version.rb
45
51
  homepage: http://github.com/maxpleaner/sinatra_sockets
@@ -1,2 +0,0 @@
1
- class Routes
2
- end