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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +41 -0
- data/lib/hibira/engine.rb +6 -0
- data/lib/hibira/version.rb +1 -1
- data/lib/hibira/waiting_room.rb +174 -0
- data/lib/hibira.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97801ac1dab195ec5c073b1da8db5dd483529a5ea1db1a00da08de5630610ec1
|
|
4
|
+
data.tar.gz: fa07c470a004a5340caa5fd4222ed959aa379e2c196ef5cd98f10e2448bb109e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+

|
|
4
|
+
|
|
3
5
|
[](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
|
data/lib/hibira/version.rb
CHANGED
|
@@ -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.
|
|
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
|