sinatra_sockets 0.0.6 → 0.0.7

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.
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