bellhop-rails 1.0.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +270 -0
- data/Rakefile +12 -0
- data/app/controllers/bellhop/admin/agents_controller.rb +37 -0
- data/app/controllers/bellhop/admin/base_controller.rb +38 -0
- data/app/controllers/bellhop/claims_controller.rb +60 -0
- data/app/controllers/bellhop/transport_controller.rb +71 -0
- data/app/controllers/bellhop/webhooks_controller.rb +31 -0
- data/app/models/bellhop/agent.rb +154 -0
- data/app/models/bellhop/application_record.rb +7 -0
- data/app/models/bellhop/print_job.rb +129 -0
- data/app/models/bellhop/session.rb +60 -0
- data/app/views/bellhop/admin/_bell.html.erb +5 -0
- data/app/views/bellhop/admin/_style.html.erb +761 -0
- data/app/views/bellhop/admin/agents/index.html.erb +170 -0
- data/app/views/bellhop/admin/agents/pair.html.erb +45 -0
- data/app/views/bellhop/admin/agents/problem.html.erb +22 -0
- data/app/views/layouts/bellhop/admin.html.erb +52 -0
- data/config/routes.rb +30 -0
- data/lib/bellhop/backplane.rb +28 -0
- data/lib/bellhop/cable/connection.rb +121 -0
- data/lib/bellhop/cable.rb +64 -0
- data/lib/bellhop/claim_exchange.rb +43 -0
- data/lib/bellhop/configuration.rb +150 -0
- data/lib/bellhop/connection.rb +46 -0
- data/lib/bellhop/doctor.rb +152 -0
- data/lib/bellhop/engine.rb +26 -0
- data/lib/bellhop/errors.rb +51 -0
- data/lib/bellhop/licensing.rb +105 -0
- data/lib/bellhop/print_options.rb +108 -0
- data/lib/bellhop/protocol.rb +102 -0
- data/lib/bellhop/refresh_credentials_job.rb +10 -0
- data/lib/bellhop/registry.rb +160 -0
- data/lib/bellhop/renew_credentials_job.rb +10 -0
- data/lib/bellhop/retire_deactivated_agents_job.rb +10 -0
- data/lib/bellhop/testing.rb +145 -0
- data/lib/bellhop/tokens.rb +30 -0
- data/lib/bellhop/version.rb +6 -0
- data/lib/bellhop/webhook_verifier.rb +83 -0
- data/lib/bellhop-rails.rb +4 -0
- data/lib/bellhop.rb +175 -0
- data/lib/generators/bellhop/admin/admin_generator.rb +45 -0
- data/lib/generators/bellhop/install/install_generator.rb +57 -0
- data/lib/generators/bellhop/install/templates/initializer.rb +41 -0
- data/lib/generators/bellhop/install/templates/migration.rb +77 -0
- data/lib/tasks/bellhop.rake +61 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1927cc2855283474e17da014fdcb1f995f1204315a74d19ccd55ba280cf604a1
|
|
4
|
+
data.tar.gz: 647ab3f40a1d4f59b7522ba81e072e0c22544da07f6940113f9fb3ff8c9e4fed
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c03cef5dfaae91754005bbcfed545b4af33ebc0114f8f5345444301edf1ee39a21edd4f980e335deaaaf3b731f97a7eb74b543e2bc89d7ffc66af24e7fb688b4
|
|
7
|
+
data.tar.gz: e267b73e0d9b43c5b4d016a40f24aa5ae29b92bf4e73ba061fd6de0ce07cbc821a6e5fb8d73d25c813c9cf5f046985650e364e86f83e0df574a0c74593e7bbf9
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Fae Software Limited
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Bellhop for Rails
|
|
2
|
+
|
|
3
|
+
Print to the label printers and read the USB scales at a physical location,
|
|
4
|
+
from your Rails application. The connection runs from your server to the
|
|
5
|
+
machine at that location, and nothing sits in the middle of it.
|
|
6
|
+
|
|
7
|
+
You need an app on [bellhop.dev](https://bellhop.dev). That is where agents
|
|
8
|
+
are licensed and where your secret key comes from.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle add bellhop-rails
|
|
12
|
+
bin/rails generate bellhop:install
|
|
13
|
+
bin/rails db:migrate
|
|
14
|
+
bin/rails bellhop:doctor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The generator writes the initializer and the migration, and mounts the engine
|
|
18
|
+
at `/bellhop`. Two settings in the initializer matter.
|
|
19
|
+
|
|
20
|
+
`secret_key` is on your app's page on bellhop.dev. Keep it in credentials.
|
|
21
|
+
|
|
22
|
+
`public_url` is your application's public base URL. Its host is the **pairing
|
|
23
|
+
host**: it goes in every pairing link, every credential is bound to it, and
|
|
24
|
+
the agent compares it byte for byte. Pick it once. Changing it after agents
|
|
25
|
+
have paired means all of them re-pair. Your socket can live on another host;
|
|
26
|
+
the `transports` block in the claim response is for that.
|
|
27
|
+
|
|
28
|
+
Ruby 3.2 and Rails 7.2 or later.
|
|
29
|
+
|
|
30
|
+
## Using it
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Add a location, then put the link in front of whoever is at the desk.
|
|
34
|
+
agent = Bellhop::Agent.provision(label: "Shipping Desk")
|
|
35
|
+
agent.pairing_link # bellhop://pair?server=…&claim=…
|
|
36
|
+
|
|
37
|
+
agent.print(kind: "label", format: :zpl, data: zpl)
|
|
38
|
+
|
|
39
|
+
# Anything large goes by URL. The block runs once the job exists, so a
|
|
40
|
+
# signed URL can name it.
|
|
41
|
+
agent.print(kind: "packing_slip", format: :pdf) { |job| document_url(job, sig: sign(job)) }
|
|
42
|
+
|
|
43
|
+
# Name a printer, and say how, when the desk's default is not what you want.
|
|
44
|
+
agent.print(
|
|
45
|
+
kind: "packing_slip", format: :pdf,
|
|
46
|
+
printer: "Office_HP_LaserJet",
|
|
47
|
+
options: { copies: 2, duplex: "long-edge", paper: "Letter" }
|
|
48
|
+
) { |job| document_url(job, sig: sign(job)) }
|
|
49
|
+
|
|
50
|
+
# What the desk shared in its most recent hello.
|
|
51
|
+
agent.printers
|
|
52
|
+
# => [{ "id" => "Office_HP_LaserJet", "name" => "Office HP LaserJet",
|
|
53
|
+
# "capabilities" => { "papers" => ["Letter", "Legal", "A4"], "duplex" => true, ... } }]
|
|
54
|
+
|
|
55
|
+
# Hear back from it.
|
|
56
|
+
ActiveSupport::Notifications.subscribe("weight.bellhop") do |event|
|
|
57
|
+
ShippingForm.fill(event.payload[:agent], grams: event.payload[:grams])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ActiveSupport::Notifications.subscribe("ack.bellhop") do |event|
|
|
61
|
+
job = event.payload[:job]
|
|
62
|
+
Rails.logger.warn("#{job.id} failed: #{job.error}") if job.status == "failed"
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`kind` is yours; the agent never reads it. Formats are `zpl`, `pdf`, `gif`,
|
|
67
|
+
and `raw`. Raw delivers any byte stream to the queue untouched: ESC/POS
|
|
68
|
+
receipts, EPL, whatever your hardware speaks.
|
|
69
|
+
|
|
70
|
+
A job that names a `printer` goes to it, by the `id` from the agent's
|
|
71
|
+
inventory. A job that names none routes by format to the printer the operator
|
|
72
|
+
picked for labels or for documents. If the agent is offline the job stays
|
|
73
|
+
pending and goes out at its next handshake. Machines sleep overnight; that is
|
|
74
|
+
normal.
|
|
75
|
+
|
|
76
|
+
`options` is a closed set: `copies`, `duplex`, `paper`, `bin`, `dpi`,
|
|
77
|
+
`color`, `pages`, `rotate`, `fit`, `collate`, `nup`. An option you leave out
|
|
78
|
+
means whatever the printer does by default.
|
|
79
|
+
|
|
80
|
+
Anything the gem can check without an agent, it checks before sending, and
|
|
81
|
+
raises `Bellhop::AgentError` at your own line: a format the agent has not
|
|
82
|
+
advertised, a printer its last hello did not report, an option the protocol
|
|
83
|
+
does not define, an option that does not apply to the format, a value outside
|
|
84
|
+
its range, a malformed page range, an inline document over 50 MB. What only
|
|
85
|
+
the desk can know (does that laser printer hold Letter?) the agent checks
|
|
86
|
+
before printing and reports in the ack, with an `error_code` your code can
|
|
87
|
+
branch on.
|
|
88
|
+
|
|
89
|
+
## When it does not pair
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
$ bin/rails bellhop:doctor
|
|
93
|
+
|
|
94
|
+
✓ pairing host deliver.example.com
|
|
95
|
+
✓ secret key authenticated as "Deliver"
|
|
96
|
+
✓ plan fleet, 34/100 agents
|
|
97
|
+
✓ scales allowed on this plan
|
|
98
|
+
✓ signing keys https://bellhop.dev publishes 2026-08
|
|
99
|
+
✓ cable adapter solid_cable
|
|
100
|
+
✓ transports websocket and http
|
|
101
|
+
✓ poll budget poll_seconds is 0, so no request is ever held
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Every one of those failures looks the same from the outside. Start here.
|
|
105
|
+
|
|
106
|
+
## Transports
|
|
107
|
+
|
|
108
|
+
Both are mounted. The agent prefers the socket and falls back to HTTP by
|
|
109
|
+
itself after three failed upgrades, which is what gets a shipping desk behind
|
|
110
|
+
a corporate proxy working.
|
|
111
|
+
|
|
112
|
+
The socket is Action Cable's server with Action Cable's protocol stripped off.
|
|
113
|
+
The wire carries flat JSON: no subscription handshake, no channel identifier,
|
|
114
|
+
no JSON inside JSON, and no welcome message. What Action Cable provides is
|
|
115
|
+
connection lifecycle and a pubsub backplane, so a print created on one Puma
|
|
116
|
+
worker reaches a socket held by another. The engine runs its own
|
|
117
|
+
`ActionCable::Server::Base`, so your application's cable configuration is
|
|
118
|
+
untouched. Request forgery protection is off on that instance only. The agent
|
|
119
|
+
is a native application and sends no `Origin` header, and Action Cable
|
|
120
|
+
refuses a connection without one. There is no browser and no cookie in play,
|
|
121
|
+
so there is nothing to protect.
|
|
122
|
+
|
|
123
|
+
No Action Cable at all, as in an API-only or `--minimal` application? The
|
|
124
|
+
engine skips the socket route, advertises HTTP alone, and `doctor` says so.
|
|
125
|
+
|
|
126
|
+
### `poll_seconds` is 0 by default
|
|
127
|
+
|
|
128
|
+
Rails serves requests from a fixed Puma thread pool, three per worker out of
|
|
129
|
+
the box. A held poll occupies one thread for its whole duration. Three agents
|
|
130
|
+
on a 25 second poll would park every thread and your application would stop
|
|
131
|
+
serving.
|
|
132
|
+
|
|
133
|
+
At 0 the request returns at once, the agent settles into polling every 3
|
|
134
|
+
seconds, and a label comes out within 3 seconds of being created. Nobody
|
|
135
|
+
standing at a printer notices that. Raise it only after counting your
|
|
136
|
+
threads; `doctor` does the arithmetic against your agent count.
|
|
137
|
+
|
|
138
|
+
## Running more than one process
|
|
139
|
+
|
|
140
|
+
The socket needs a real pubsub adapter: `solid_cable` on a default Rails 8
|
|
141
|
+
app, or Redis. With the `async` adapter a print created by one worker cannot
|
|
142
|
+
reach a socket held by another, and `doctor` warns you. HTTP has no such
|
|
143
|
+
need. Its queue is a table row, so any process can write to it.
|
|
144
|
+
|
|
145
|
+
One caveat on `solid_cable`. It polls the database from every process, and
|
|
146
|
+
this gem subscribes one channel per connected agent, so the backplane itself
|
|
147
|
+
becomes database load as the fleet grows. It is fine to about a thousand
|
|
148
|
+
agents. Plan on Redis beyond that; `doctor` tracks your fleet size and says
|
|
149
|
+
when.
|
|
150
|
+
|
|
151
|
+
Bellhop's socket server inherits the app's cable adapter. To give it its
|
|
152
|
+
own, set `config.cable` to a hash in the shape of `cable.yml`. An app on
|
|
153
|
+
AnyCable has to: AnyCable's adapter only broadcasts to the Go server and
|
|
154
|
+
cannot subscribe from Rails, so a print created on one worker would never
|
|
155
|
+
reach a socket held by another, and the engine refuses to boot on it.
|
|
156
|
+
Point Bellhop at Solid Cable or Redis instead:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
config.cable = { adapter: "solid_cable" }
|
|
160
|
+
config.cable = { adapter: "redis", url: ENV["REDIS_URL"] }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Solid Cable reads `connects_to` and its polling settings from `cable.yml`
|
|
164
|
+
whatever adapter line that file carries, so those stay put. The agent's
|
|
165
|
+
socket stays on Puma and never touches AnyCable.
|
|
166
|
+
|
|
167
|
+
## Plan changes
|
|
168
|
+
|
|
169
|
+
Entitlements live in the credential and are read when it is minted. On its
|
|
170
|
+
own, a plan change waits for the next renewal. Renewal is automatic as agents
|
|
171
|
+
connect, and `rails bellhop:renew` sweeps on demand.
|
|
172
|
+
|
|
173
|
+
Register `https://deliver.example.com/bellhop/webhook` on your app's page on
|
|
174
|
+
bellhop.dev and the wait goes away. bellhop.dev calls when the plan changes,
|
|
175
|
+
the engine verifies the delivery against the published signing keys, and
|
|
176
|
+
every paired agent is pushed a credential with the new entitlements.
|
|
177
|
+
bellhop.dev also calls when an agent is removed there, and the engine retires
|
|
178
|
+
it here too: connection closed, row gone. There is no webhook secret to hold.
|
|
179
|
+
Behind it are `Bellhop.refresh!` and `Bellhop.retire_deactivated!`, which you
|
|
180
|
+
can also call by hand. Each runs from Active Job when your app has it and
|
|
181
|
+
inline when it does not.
|
|
182
|
+
|
|
183
|
+
## The admin
|
|
184
|
+
|
|
185
|
+
It is at the engine's mount point. It is open in development and refuses to
|
|
186
|
+
render anywhere else until you say who may see it:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
config.admin_authenticator = ->(controller) { controller.current_user&.admin? }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
It lists your agents and can print to them or unpair them, so it should not
|
|
193
|
+
become reachable just because the engine is mounted.
|
|
194
|
+
|
|
195
|
+
It ships with its own standalone layout and a `<style>` block, in the same
|
|
196
|
+
plum and blush as bellhop.dev. Nothing to precompile, nothing that leaks into
|
|
197
|
+
the rest of your application. The fonts load from Google Fonts; delete that
|
|
198
|
+
`<link>` from the layout and the stacks fall back to Georgia and the system
|
|
199
|
+
sans.
|
|
200
|
+
|
|
201
|
+
To change how it looks, or to put it inside your own layout:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
bin/rails generate bellhop:admin
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
That copies the controllers and views into your application, stylesheet
|
|
208
|
+
partial included. Rails resolves your paths ahead of an engine's, so from
|
|
209
|
+
then on yours render.
|
|
210
|
+
|
|
211
|
+
### The test label
|
|
212
|
+
|
|
213
|
+
The admin's **Label** button prints `Bellhop.test_label`: the Bellhop mark and
|
|
214
|
+
two lines of text, anchored to the top left corner so it comes out whole on
|
|
215
|
+
anything from 1.25in stock up. ZPL has no way to ask a printer how wide its
|
|
216
|
+
stock is, so the default never assumes a width.
|
|
217
|
+
|
|
218
|
+
When you know the width, say so in dots and the label is centred across it:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
Bellhop.test_label(width: 812) # 4in at 203 dpi
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Testing
|
|
225
|
+
|
|
226
|
+
Printing to a real agent is the right way to check that a label comes out.
|
|
227
|
+
For CI, where no agent exists:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
require "bellhop/testing"
|
|
231
|
+
|
|
232
|
+
agent = Bellhop::Agent.provision(label: "Test")
|
|
233
|
+
agent = Bellhop::Testing::FakeAgent.claim(agent)
|
|
234
|
+
|
|
235
|
+
agent.print(kind: "label", format: :zpl, data: zpl)
|
|
236
|
+
|
|
237
|
+
assert_equal 1, agent.printed.size
|
|
238
|
+
assert_includes agent.printed.first[:data], "^XA"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`FakeAgent` speaks the protocol the way the real agent does. It deduplicates
|
|
242
|
+
by job id and answers `ping`. `fail_prints:` exercises your error path,
|
|
243
|
+
`capabilities:` exercises the gate on formats an agent cannot handle,
|
|
244
|
+
`printers:` shapes the inventory it advertises, and `default_printers:` sets
|
|
245
|
+
the role map. Each entry in `printed` carries the `printer` and `options` the
|
|
246
|
+
job named.
|
|
247
|
+
|
|
248
|
+
## What it will not do
|
|
249
|
+
|
|
250
|
+
**Validate inbound messages against the schemas.** Both sides ignore what
|
|
251
|
+
they do not recognise. That rule is the protocol's only forward-compatibility
|
|
252
|
+
mechanism, and enforcing schemas would break it.
|
|
253
|
+
|
|
254
|
+
**Log tokens.** Claim tokens, agent tokens, credentials, and the
|
|
255
|
+
`Authorization` header stay out of log lines. `bellhop:pair` prints a link
|
|
256
|
+
that nothing else records, because the link is the claim token.
|
|
257
|
+
|
|
258
|
+
**Redeliver on every `hello`.** A `hello` arrives mid-session whenever an
|
|
259
|
+
operator changes a printer. Re-sending outstanding jobs each time can outrun
|
|
260
|
+
the agent's deduplication ledger and print the same label several times. Only
|
|
261
|
+
the first `hello` on a connection redelivers. A reconnect is a new
|
|
262
|
+
connection, so at-least-once delivery still holds.
|
|
263
|
+
|
|
264
|
+
## Reference
|
|
265
|
+
|
|
266
|
+
The protocol reference at https://bellhop.dev/docs/protocol is normative.
|
|
267
|
+
Where this gem disagrees with it, the gem is wrong. The integration guide is
|
|
268
|
+
at https://bellhop.dev/docs.
|
|
269
|
+
|
|
270
|
+
MIT licence.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bellhop
|
|
4
|
+
module Admin
|
|
5
|
+
class AgentsController < BaseController
|
|
6
|
+
def index
|
|
7
|
+
@agents = Agent.order(:id)
|
|
8
|
+
@jobs = PrintJob.recent.limit(20).includes(:agent)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@agent = Agent.provision(label: params.require(:label))
|
|
13
|
+
render :pair
|
|
14
|
+
rescue LicensingError => e
|
|
15
|
+
@error = e
|
|
16
|
+
render :problem, status: :bad_gateway
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def repair
|
|
20
|
+
@agent = Agent.find(params[:id]).repair!
|
|
21
|
+
render :pair
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def print
|
|
25
|
+
Agent.find(params[:id]).print(kind: "label", format: :zpl, data: Bellhop.test_label)
|
|
26
|
+
redirect_to agents_path
|
|
27
|
+
rescue AgentError => e
|
|
28
|
+
redirect_to agents_path, alert: e.message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remove
|
|
32
|
+
Agent.find(params[:id]).decommission!
|
|
33
|
+
redirect_to agents_path
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bellhop
|
|
4
|
+
module Admin
|
|
5
|
+
# Open in development, and refuses to render anywhere else until
|
|
6
|
+
# `config.admin_authenticator` is set. `rails generate bellhop:admin`
|
|
7
|
+
# copies these controllers and views into your application.
|
|
8
|
+
class BaseController < ActionController::Base
|
|
9
|
+
protect_from_forgery with: :exception
|
|
10
|
+
|
|
11
|
+
layout "bellhop/admin"
|
|
12
|
+
|
|
13
|
+
before_action :authenticate_admin
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def authenticate_admin
|
|
17
|
+
authenticator = Bellhop.config.admin_authenticator
|
|
18
|
+
|
|
19
|
+
if authenticator
|
|
20
|
+
head :forbidden unless authenticator.call(self)
|
|
21
|
+
elsif !Rails.env.local?
|
|
22
|
+
render plain: <<~MESSAGE, status: :forbidden
|
|
23
|
+
The Bellhop admin is not configured.
|
|
24
|
+
|
|
25
|
+
It lists your agents and can print to them or unpair them, so it
|
|
26
|
+
refuses to render outside development until you say who may see it:
|
|
27
|
+
|
|
28
|
+
Bellhop.configure do |config|
|
|
29
|
+
config.admin_authenticator = ->(controller) { controller.current_user&.admin? }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Or stop mounting the engine's admin and use your own.
|
|
33
|
+
MESSAGE
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bellhop
|
|
4
|
+
# Pairing. Runs once per agent, in front of a person at a printer, and is
|
|
5
|
+
# the only unauthenticated route the engine owns. The exchange itself lives
|
|
6
|
+
# in Bellhop::ClaimExchange; this is the HTTP shell around it.
|
|
7
|
+
class ClaimsController < ActionController::API
|
|
8
|
+
before_action :enforce_rate_limit
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
if params[:claim_token].blank?
|
|
12
|
+
return fail_with(:bad_request, "invalid_request", "That pairing link is missing its claim token.")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
result = ClaimExchange.perform(params[:claim_token])
|
|
16
|
+
|
|
17
|
+
render json: {
|
|
18
|
+
# Appears in this response and nowhere else.
|
|
19
|
+
agent_token: result.agent_token,
|
|
20
|
+
agent_name: result.agent.label,
|
|
21
|
+
app_name: result.agent.app_name.presence || Bellhop.config.app_name || "Bellhop",
|
|
22
|
+
accent_color: result.agent.accent_color.presence || Bellhop.config.accent_color,
|
|
23
|
+
credential: result.activation["credential"],
|
|
24
|
+
transports: transports
|
|
25
|
+
}.compact
|
|
26
|
+
rescue ClaimExchange::ExpiredClaim
|
|
27
|
+
fail_with(:not_found, "claim_expired", "That pairing link has expired. Ask for a new one.")
|
|
28
|
+
rescue LicensingError => e
|
|
29
|
+
Bellhop.logger.warn { "[bellhop] activation failed (#{e.code}); the claim token stays valid" }
|
|
30
|
+
fail_with(:bad_gateway, e.code, e.operator_message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
# Where to connect, most preferred first. This is the hook for moving
|
|
35
|
+
# the socket to another host; the credential does not care where it is.
|
|
36
|
+
def transports
|
|
37
|
+
list = []
|
|
38
|
+
list << { type: "websocket", url: Bellhop.config.socket_url } if Bellhop.cable_available?
|
|
39
|
+
list << { type: "http", url: Bellhop.config.http_url }
|
|
40
|
+
list
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The agent shows `message` to whoever is at the printer.
|
|
44
|
+
def fail_with(status, code, message)
|
|
45
|
+
render status: status, json: { error: { code: code, message: message } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enforce_rate_limit
|
|
49
|
+
limit = Bellhop.config.claim_rate_limit
|
|
50
|
+
return if limit.blank?
|
|
51
|
+
|
|
52
|
+
key = "bellhop:claim:#{request.remote_ip}"
|
|
53
|
+
Rails.cache.write(key, 0, expires_in: 1.minute, unless_exist: true)
|
|
54
|
+
count = Rails.cache.increment(key, 1) || 1
|
|
55
|
+
return if count <= limit
|
|
56
|
+
|
|
57
|
+
fail_with(:too_many_requests, "too_many_requests", "Too many pairing attempts. Wait a minute and try again.")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bellhop
|
|
4
|
+
# The HTTP transport: three routes and a table. This is what works behind
|
|
5
|
+
# corporate proxies that break WebSocket upgrades, and it is the whole
|
|
6
|
+
# transport on a host with no Action Cable.
|
|
7
|
+
class TransportController < ActionController::API
|
|
8
|
+
before_action :authenticate
|
|
9
|
+
before_action :find_session, except: :create
|
|
10
|
+
|
|
11
|
+
# POST /sessions. The body is `hello`.
|
|
12
|
+
def create
|
|
13
|
+
hello = params.to_unsafe_h.slice(*%w[type protocol_version agent_version platform session_id capabilities printers default_printers])
|
|
14
|
+
return render(status: :bad_request, json: { error: "expected_hello" }) if hello["type"] != "hello"
|
|
15
|
+
|
|
16
|
+
if hello["protocol_version"].to_i != PROTOCOL_VERSION
|
|
17
|
+
return render(status: :upgrade_required, json: { error: "unsupported_version" })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
session = Session.open!(@agent)
|
|
21
|
+
connection = HttpConnection.new(session, handshake: true)
|
|
22
|
+
|
|
23
|
+
# Protocol.receive queues `ready` and then any outstanding jobs. `ready`
|
|
24
|
+
# goes inline in this response; the rest waits for the first poll.
|
|
25
|
+
Protocol.receive(@agent, connection, hello)
|
|
26
|
+
queued = session.drain
|
|
27
|
+
ready = queued.shift
|
|
28
|
+
queued.each { |message| session.push(message) }
|
|
29
|
+
|
|
30
|
+
render status: :created, json: {
|
|
31
|
+
session_id: session.id,
|
|
32
|
+
poll_seconds: Bellhop.config.poll_seconds,
|
|
33
|
+
message: ready
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /sessions/:id/messages. An empty result is normal.
|
|
38
|
+
def index
|
|
39
|
+
render json: { messages: @session.drain }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# POST /sessions/:id/messages, batched. Anything queued meanwhile rides
|
|
43
|
+
# back in the response.
|
|
44
|
+
def update
|
|
45
|
+
connection = HttpConnection.new(@session, handshake: false)
|
|
46
|
+
Array(params[:messages]).each do |message|
|
|
47
|
+
Protocol.receive(@agent, connection, message.to_unsafe_h)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
render json: { messages: @session.drain }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# DELETE /sessions/:id. Expiry covers the case where it never arrives.
|
|
54
|
+
def destroy
|
|
55
|
+
@session.destroy
|
|
56
|
+
render json: {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
def authenticate
|
|
61
|
+
@agent = Agent.authenticate(Tokens.bearer(request.headers["Authorization"]))
|
|
62
|
+
render(status: :unauthorized, json: { error: "unauthorized" }) unless @agent
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A 404 means "open a new one".
|
|
66
|
+
def find_session
|
|
67
|
+
@session = @agent.sessions.find_by(id: params[:id])
|
|
68
|
+
render(status: :not_found, json: { error: "no_such_session" }) unless @session
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bellhop
|
|
4
|
+
# bellhop.dev's webhook. The event name is signed, so a verified delivery is
|
|
5
|
+
# safe to dispatch on. `agent.deactivated` retires the removed agents.
|
|
6
|
+
# Anything else, including events this version has never heard of, means
|
|
7
|
+
# entitlements moved: re-mint every paired agent's credential. The work runs
|
|
8
|
+
# from a job so this answers at once however large the fleet.
|
|
9
|
+
class WebhooksController < ActionController::API
|
|
10
|
+
def create
|
|
11
|
+
unless WebhookVerifier.valid?(request.headers["Bellhop-Signature"],
|
|
12
|
+
event: params[:event].to_s, app: params[:app].to_s)
|
|
13
|
+
return render status: :unauthorized, json: { error: "invalid_signature" }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
case params[:event]
|
|
17
|
+
when "agent.deactivated"
|
|
18
|
+
Bellhop.logger.info { "[bellhop] webhook received (#{params[:event]}); retiring removed agents" }
|
|
19
|
+
Bellhop.retire_deactivated_later
|
|
20
|
+
else
|
|
21
|
+
Bellhop.logger.info { "[bellhop] webhook received (#{params[:event]}); refreshing credentials" }
|
|
22
|
+
Bellhop.refresh_later
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
render status: :accepted, json: { ok: true }
|
|
26
|
+
rescue LicensingError
|
|
27
|
+
# The key set could not be fetched. A 5xx makes bellhop.dev redeliver.
|
|
28
|
+
head :service_unavailable
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|