hibira-rails 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec8dd25bdad390a25709965fcd822ac5882f32a09ba94ae218a61f0f3bf88463
4
- data.tar.gz: 0f75219446b55e2a1385861a14e69bc33a7f77eefc6642099ba33f9a38326345
3
+ metadata.gz: 97801ac1dab195ec5c073b1da8db5dd483529a5ea1db1a00da08de5630610ec1
4
+ data.tar.gz: fa07c470a004a5340caa5fd4222ed959aa379e2c196ef5cd98f10e2448bb109e
5
5
  SHA512:
6
- metadata.gz: 90e64f04f5b36d52f038dce3c950730b3b85a182981969dd41764bb9626f4e9f03dc2d1f6190959175d294dc4e9c1c7fd05cbe38b13ea63a3a11800da49ef89d
7
- data.tar.gz: 0743615164b04d789f48c40eac56507c92a8462bdc212f9d2638698f916833ee25083527858103b007b4ec5f6faf8dae85fcd9bc9dc370e710d0ab5b25bc9b9b
6
+ metadata.gz: e096f8e71fede42bd5eb05d7225470400ea4862b1fa02a73ac05974d8e9b6033495ef819fd31b999c726c89b606ce6cf6437e6520958188ae68287dc61d174ce
7
+ data.tar.gz: 13073b62ab4a928572f5525606e491005202ad5c2136baddb79678df359c4a08bb3716500d702b28d64172ebfbc4ae7baa2594501f4bfe0e1d4926500f48d896
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Waiting rooms: `waiting_room "presale", only: :show` in controllers and
6
+ `Hibira::WaitingRoom::Middleware` for path prefixes send visitors without a
7
+ pass to the Hibira queue and let them in with a signed pass kept in an
8
+ HttpOnly cookie (`hibira_pass` in the controller).
9
+ `Hibira::WaitingRoom.verify_pass` checks a pass anywhere.
10
+
11
+ ## 0.1.1
12
+
13
+ - README: cover image and screenshots.
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  - Turbo Streams over Hibira: every Turbo broadcast (`broadcasts_to`,
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # hibira-rails
2
2
 
3
+ ![hibira-rails](.github/cover.jpg)
4
+
3
5
  [![ci](https://github.com/hibira-io/rails/actions/workflows/ci.yml/badge.svg)](https://github.com/hibira-io/rails/actions/workflows/ci.yml)
4
6
 
5
7
  Hotwire Turbo Streams over [Hibira](https://hibira.io), with no Action Cable
@@ -44,6 +46,11 @@ hibira:
44
46
  secret: your-app-secret
45
47
  ```
46
48
 
49
+ The secret is shown only once, when you create the app; the app ID and key
50
+ stay under Overview → Credentials. On the Free plan, allowed origins can't be
51
+ `*`: add every origin your pages load from (`http://localhost:3000` while
52
+ developing, plus your production domain), or browsers are refused.
53
+
47
54
  Then check them:
48
55
 
49
56
  ```sh
@@ -190,6 +197,40 @@ Hibira.push.publish_to_users(
190
197
  web, Android and iOS SDKs. It answers only for the signed-in user
191
198
  (`current_user` or `Current.user`; change it with `config.push_user_id`).
192
199
 
200
+ ## Waiting room
201
+
202
+ Put a [Hibira waiting room](https://github.com/hibira-io/waiting-room) in front
203
+ of a ticket drop or launch page. Visitors without a pass are sent to the hosted
204
+ queue page; when it's their turn they come back with a signed pass, which is
205
+ checked and kept in an HttpOnly cookie.
206
+
207
+ ```ruby
208
+ class DropsController < ApplicationController
209
+ waiting_room "presale", only: :show
210
+
211
+ def show
212
+ @number = hibira_pass.number # also ticket_id, expires_at
213
+ end
214
+ end
215
+ ```
216
+
217
+ Or protect path prefixes in any Rack app:
218
+
219
+ ```ruby
220
+ # config/application.rb
221
+ config.middleware.use Hibira::WaitingRoom::Middleware, "/drop" => "presale"
222
+ ```
223
+
224
+ Non-GET requests without a pass get a `403`. Create the room (rate, start
225
+ time, `return_urls`) with the
226
+ [waiting rooms API](https://github.com/hibira-io/pusher-server/blob/master/docs/waiting-rooms.md).
227
+ A pass is valid for 30 minutes by default and works in any browser; if each
228
+ queue number may check out once, record `hibira_pass.ticket_id` and refuse a
229
+ repeat. `Hibira::WaitingRoom.verify_pass(token, room: "presale")` checks a pass
230
+ anywhere else.
231
+
232
+ 中文摘要:在搶票或限量開賣的 controller 加上 `waiting_room "presale"`(或用 Rack middleware 保護路徑),沒有 pass 的訪客會被導到 Hibira 託管的排隊頁,輪到時帶著簽名 pass 回來並存成 HttpOnly cookie。
233
+
193
234
  ## Settings
194
235
 
195
236
  | Setting | Environment | Default |
data/lib/hibira/engine.rb CHANGED
@@ -26,6 +26,12 @@ module Hibira
26
26
  ActiveSupport.on_load(:action_view) do
27
27
  include Hibira::StreamsHelper
28
28
  end
29
+ ActiveSupport.on_load(:action_controller_base) do
30
+ include Hibira::WaitingRoom::Controller
31
+ end
32
+ ActiveSupport.on_load(:action_controller_api) do
33
+ include Hibira::WaitingRoom::Controller
34
+ end
29
35
  end
30
36
 
31
37
  # The Hibira routes are drawn straight into the app's routes (like Active
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hibira
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "rack"
7
+ require "active_support/concern"
8
+
9
+ module Hibira
10
+ # Waiting rooms: send visitors to the Hibira queue, let them in with a pass.
11
+ #
12
+ # In a controller:
13
+ #
14
+ # class DropsController < ApplicationController
15
+ # waiting_room "presale", only: :show
16
+ #
17
+ # def show
18
+ # hibira_pass.number # also ticket_id, expires_at
19
+ # end
20
+ # end
21
+ #
22
+ # Or for path prefixes in any Rack app:
23
+ #
24
+ # config.middleware.use Hibira::WaitingRoom::Middleware, "/drop" => "presale"
25
+ #
26
+ # A visitor without a valid pass is redirected to the hosted waiting page
27
+ # (non-GET requests get a 403). When it's their turn the page sends them
28
+ # back with ?hibira_pass=..., which is checked, stored in an HttpOnly cookie
29
+ # (hibira_wr_<room>) and removed from the URL.
30
+ module WaitingRoom
31
+ PURPOSE = "hibira-waitroom-pass."
32
+ PARAM = "hibira_pass"
33
+ ENV_KEY = "hibira.pass"
34
+
35
+ Pass = Struct.new(:app_id, :room, :number, :ticket_id, :issued_at, :expires_at, keyword_init: true)
36
+
37
+ class << self
38
+ # Returns the pass's claims, or nil when it is missing, forged, expired,
39
+ # or for another room or app. secret may be an array during a rotation.
40
+ def verify_pass(token, secret: Hibira.config.secret, room: nil, app_id: nil, now: Time.now.to_f)
41
+ return unless token.is_a?(String) && token.length <= 1024 && token.count(".") == 1
42
+
43
+ payload, sig = token.split(".", 2)
44
+ sig = decode(sig)
45
+ return if payload.empty? || sig.nil? || sig.bytesize != 32
46
+
47
+ message = PURPOSE + payload
48
+ valid = Array(secret).compact.reject(&:empty?).any? do |s|
49
+ Rack::Utils.secure_compare(OpenSSL::HMAC.digest("SHA256", s, message), sig)
50
+ end
51
+ return unless valid
52
+
53
+ claims = JSON.parse(decode(payload).to_s) rescue nil
54
+ return unless claims.is_a?(Hash) && claims["v"] == 1 && claims["exp"].is_a?(Numeric)
55
+ return if now >= claims["exp"]
56
+ return if room && claims["room"] != room
57
+ return if app_id && claims["app"] != app_id.to_s
58
+
59
+ Pass.new(app_id: claims["app"], room: claims["room"], number: claims["n"], ticket_id: claims["tid"],
60
+ issued_at: claims["iat"], expires_at: claims["exp"].to_i)
61
+ end
62
+
63
+ # The hosted waiting page address for a room.
64
+ def url(room, return_url = nil, app_key: Hibira.config.key, host: nil, lang: nil)
65
+ base = "#{(host || default_host).chomp('/')}/wait/#{escape(app_key)}/#{escape(room)}"
66
+ query = { "return" => return_url, "lang" => lang }.compact
67
+ query.empty? ? base : "#{base}?#{Rack::Utils.build_query(query)}"
68
+ end
69
+
70
+ # Checks a Rack request. Returns nil to let it through (the claims are in
71
+ # env["hibira.pass"]), or the Rack response to send instead.
72
+ def check(env, room, secret: nil, app_key: nil, host: nil, lang: nil, cookie_name: nil)
73
+ secret ||= Hibira.config.secret
74
+ raise MissingCredentials, "Waiting rooms need HIBIRA_APP_SECRET" if Array(secret).compact.empty?
75
+
76
+ request = Rack::Request.new(env)
77
+ name = cookie_name || "hibira_wr_#{room}"
78
+ app_id = Hibira.config.app_id
79
+ clean = url_without_pass(request)
80
+
81
+ fresh = request.GET[PARAM]
82
+ if fresh && (claims = verify_pass(fresh, secret: secret, room: room, app_id: app_id))
83
+ headers = { "location" => clean, "cache-control" => "no-store" }
84
+ Rack::Utils.set_cookie_header!(headers, name,
85
+ value: fresh, path: "/", httponly: true, same_site: :lax,
86
+ secure: request.ssl?, max_age: [claims.expires_at - Time.now.to_i, 0].max)
87
+ return [302, headers, []]
88
+ end
89
+
90
+ claims = verify_pass(request.cookies[name], secret: secret, room: room, app_id: app_id)
91
+ if claims
92
+ env[ENV_KEY] = claims
93
+ return
94
+ end
95
+
96
+ if %w[GET HEAD].include?(request.request_method)
97
+ location = url(room, clean, app_key: app_key || Hibira.config.key, host: host, lang: lang)
98
+ [302, { "location" => location, "cache-control" => "no-store" }, []]
99
+ else
100
+ body = JSON.generate(error: "waiting_room", message: "join the waiting room first")
101
+ [403, { "content-type" => "application/json", "cache-control" => "no-store" }, [body]]
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def default_host
108
+ c = Hibira.config
109
+ scheme = c.use_tls ? "https" : "http"
110
+ port = c.port == (c.use_tls ? 443 : 80) ? "" : ":#{c.port}"
111
+ "#{scheme}://#{c.host}#{port}"
112
+ end
113
+
114
+ def url_without_pass(request)
115
+ query = Rack::Utils.parse_query(request.query_string).except(PARAM)
116
+ base = request.base_url + request.path
117
+ query.empty? ? base : "#{base}?#{Rack::Utils.build_query(query)}"
118
+ end
119
+
120
+ def escape(value) = Rack::Utils.escape_path(value.to_s)
121
+
122
+ def decode(value)
123
+ return unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
124
+
125
+ Base64.urlsafe_decode64(value + ("=" * (-value.length % 4)))
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+ end
130
+
131
+ # Rack middleware protecting path prefixes: { "/drop" => "presale" }. The
132
+ # longest matching prefix wins. options: secret, app_key, host, lang.
133
+ class Middleware
134
+ def initialize(app, rooms, options = {})
135
+ @app = app
136
+ @rooms = rooms.transform_keys(&:to_s).sort_by { |prefix, _| -prefix.length }
137
+ @options = options
138
+ end
139
+
140
+ def call(env)
141
+ path = env["PATH_INFO"].to_s
142
+ _, room = @rooms.find { |prefix, _| path == prefix || path.start_with?(prefix.end_with?("/") ? prefix : "#{prefix}/") }
143
+ (room && WaitingRoom.check(env, room, **@options)) || @app.call(env)
144
+ end
145
+ end
146
+
147
+ # Adds `waiting_room "room", only: ...` to controllers and `hibira_pass`.
148
+ module Controller
149
+ extend ActiveSupport::Concern
150
+
151
+ class_methods do
152
+ def waiting_room(room, secret: nil, app_key: nil, host: nil, lang: nil, cookie_name: nil, **filter)
153
+ options = { secret: secret, app_key: app_key, host: host, lang: lang, cookie_name: cookie_name }
154
+ before_action(**filter) do
155
+ status, headers, body = WaitingRoom.check(request.env, room, **options)
156
+ if status
157
+ headers.each do |k, v|
158
+ case k
159
+ when "content-type" then response.content_type = v
160
+ when "location" then response.location = v
161
+ else response.set_header(k, v)
162
+ end
163
+ end
164
+ self.status = status
165
+ self.response_body = body
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ def hibira_pass = request.env[ENV_KEY]
172
+ end
173
+ end
174
+ end
data/lib/hibira.rb CHANGED
@@ -7,6 +7,7 @@ require "hibira/webhook"
7
7
  require "hibira/turbo"
8
8
  require "hibira/streams_helper"
9
9
  require "hibira/check"
10
+ require "hibira/waiting_room"
10
11
 
11
12
  # Rails integration for Hibira (https://hibira.io): Turbo Streams, private and
12
13
  # presence channel auth, webhooks and push notifications over the Pusher
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibira-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hibira
@@ -118,6 +118,7 @@ files:
118
118
  - lib/hibira/streams_helper.rb
119
119
  - lib/hibira/turbo.rb
120
120
  - lib/hibira/version.rb
121
+ - lib/hibira/waiting_room.rb
121
122
  - lib/hibira/webhook.rb
122
123
  - lib/tasks/hibira.rake
123
124
  homepage: https://hibira.io