samagotchi 0.110.0 → 0.111.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "crypto"
|
|
10
|
+
require_relative "../private_files"
|
|
11
|
+
require_relative "../../atomic_file"
|
|
12
|
+
require_relative "../../session"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
module Web
|
|
16
|
+
module Push
|
|
17
|
+
# This machine's Web Push state, in $XDG_STATE_HOME/samagotchi/web-push/
|
|
18
|
+
# (next to web-token and web-grants/; a 0700 folder, 0600 files, never
|
|
19
|
+
# in config.yml): vapid.pem, chi web's one VAPID key, and
|
|
20
|
+
# subscriptions.json, the devices that turned the bell on. A
|
|
21
|
+
# subscription holds no personal data (an opaque endpoint and two
|
|
22
|
+
# keys); the endpoint and keys never leave this store but for the push
|
|
23
|
+
# itself: what the page and the log show is Sub#public_h (the short
|
|
24
|
+
# id, the label, the dates). See docs/internals/web-push.md.
|
|
25
|
+
#
|
|
26
|
+
# chi web's request threads and the Notifier's sender thread both
|
|
27
|
+
# write: every write is a read-change-write under a lock file.
|
|
28
|
+
class Store
|
|
29
|
+
DIR = "web-push"
|
|
30
|
+
KEY_FILE = "vapid.pem"
|
|
31
|
+
SUBS_FILE = "subscriptions.json"
|
|
32
|
+
LOCK = "store.lock"
|
|
33
|
+
|
|
34
|
+
# The push services a subscription's endpoint may name: the
|
|
35
|
+
# browsers' (Apple, Google, Mozilla, Microsoft). chi web POSTs to
|
|
36
|
+
# nothing else (a subscription is the owner's, but an endpoint is a
|
|
37
|
+
# URL chi web fetches). A ".suffix" entry takes any host under it.
|
|
38
|
+
PUSH_SERVICES = ["web.push.apple.com", "fcm.googleapis.com", "updates.push.services.mozilla.com",
|
|
39
|
+
".notify.windows.com"].freeze
|
|
40
|
+
ENDPOINT_MAX = 2048
|
|
41
|
+
LABEL_MAX = 40
|
|
42
|
+
# Devices a store keeps (every subscribe adds one until it is turned
|
|
43
|
+
# off): a bound on what one push fans out to.
|
|
44
|
+
MAX_SUBS = 50
|
|
45
|
+
|
|
46
|
+
# The store's folder or a file in it can be read by others: chi web
|
|
47
|
+
# refuses to start (Server.launch).
|
|
48
|
+
class Insecure < StandardError; end
|
|
49
|
+
|
|
50
|
+
# A subscription the store won't take: the message is the page's.
|
|
51
|
+
class Invalid < StandardError; end
|
|
52
|
+
|
|
53
|
+
# subscriptions.json is there but doesn't read: no device is pushed
|
|
54
|
+
# to, and nothing writes over it (that would forget every device).
|
|
55
|
+
class Corrupt < StandardError
|
|
56
|
+
def initialize(path, reason)
|
|
57
|
+
super("#{path} can't be read (#{reason}): chi won't write over it; fix it, or move it aside and turn " \
|
|
58
|
+
"the bell on again on each device")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One device's subscription. +id+ is the short id (the first 8 hex
|
|
63
|
+
# of the endpoint's SHA-256), the only name of it outside this
|
|
64
|
+
# store. +p256dh+ and +auth+ as the browser gave them (base64url).
|
|
65
|
+
# +origin+ the public origin it subscribed on: it is sent to only
|
|
66
|
+
# while chi web runs there (a renamed machine's are stale).
|
|
67
|
+
Sub = Data.define(:id, :endpoint, :p256dh, :auth, :origin, :label, :created_at, :last_ok_at, :last_error,
|
|
68
|
+
:prefs) do
|
|
69
|
+
def self.from_store(hash)
|
|
70
|
+
keys = hash["keys"].is_a?(Hash) ? hash["keys"] : {}
|
|
71
|
+
new(id: hash["id"], endpoint: hash["endpoint"], p256dh: keys["p256dh"], auth: keys["auth"],
|
|
72
|
+
origin: hash["origin"], label: hash["label"], created_at: hash["created_at"],
|
|
73
|
+
last_ok_at: hash["last_ok_at"], last_error: hash["last_error"],
|
|
74
|
+
prefs: hash["prefs"].is_a?(Hash) ? hash["prefs"] : { "done" => false })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_store
|
|
78
|
+
{ "id" => id, "endpoint" => endpoint, "keys" => { "p256dh" => p256dh, "auth" => auth }, "origin" => origin,
|
|
79
|
+
"label" => label, "created_at" => created_at, "last_ok_at" => last_ok_at, "last_error" => last_error,
|
|
80
|
+
"prefs" => prefs }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The device's keys as bytes (Crypto.encrypt's).
|
|
84
|
+
def p256dh_bytes = Crypto.unb64u(p256dh)
|
|
85
|
+
def auth_bytes = Crypto.unb64u(auth)
|
|
86
|
+
|
|
87
|
+
# Never the endpoint or the keys: what the page, the CLI and the log show.
|
|
88
|
+
def public_h
|
|
89
|
+
{ id: id, label: label, origin: origin, created_at: created_at, last_ok_at: last_ok_at,
|
|
90
|
+
last_error: last_error }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inspect = "#<Sub #{id} #{label.inspect}>"
|
|
94
|
+
alias_method :to_s, :inspect
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# $XDG_STATE_HOME/samagotchi/web-push, next to the grants;
|
|
98
|
+
# +state_dir+ is the sessions dir (Session.default_state_dir).
|
|
99
|
+
def self.dir(state_dir: nil, env: ENV)
|
|
100
|
+
File.join(File.dirname(state_dir || Session.default_state_dir(env: env)), DIR)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.short_id(endpoint) = Digest::SHA256.hexdigest(endpoint.to_s)[0, 8]
|
|
104
|
+
|
|
105
|
+
attr_reader :dir
|
|
106
|
+
|
|
107
|
+
# @param services [Array<String>] the push services endpoints may
|
|
108
|
+
# name (PUSH_SERVICES; a spec seam, not config)
|
|
109
|
+
# @param clock [#call] the Time now
|
|
110
|
+
def initialize(dir = Store.dir, services: PUSH_SERVICES, clock: -> { Time.now })
|
|
111
|
+
@dir = dir
|
|
112
|
+
@services = services
|
|
113
|
+
@clock = clock
|
|
114
|
+
@key_mutex = Mutex.new
|
|
115
|
+
@key = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Never the key.
|
|
119
|
+
def inspect = "#<#{self.class.name} #{@dir}>"
|
|
120
|
+
alias to_s inspect
|
|
121
|
+
|
|
122
|
+
def key_path = File.join(@dir, KEY_FILE)
|
|
123
|
+
def subs_path = File.join(@dir, SUBS_FILE)
|
|
124
|
+
|
|
125
|
+
# Refuses a store others could read (PrivateFiles).
|
|
126
|
+
# @raise [Insecure]
|
|
127
|
+
def check_permissions!
|
|
128
|
+
PrivateFiles.check!(@dir, [key_path, subs_path], holds: "chi's push key and devices", error: Insecure)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# chi web's VAPID key: vapid.pem, made (0600) the first time.
|
|
132
|
+
# @return [OpenSSL::PKey::EC]
|
|
133
|
+
def vapid_key
|
|
134
|
+
@key_mutex.synchronize do
|
|
135
|
+
@key ||= locked do
|
|
136
|
+
if File.file?(key_path)
|
|
137
|
+
OpenSSL::PKey::EC.new(File.read(key_path))
|
|
138
|
+
else
|
|
139
|
+
Crypto.generate_key.tap { |key| AtomicFile.write(key_path, key.private_to_pem, perm: 0o600) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array<Sub>] none when the file is missing or corrupt (a
|
|
146
|
+
# corrupt one is refused on write: #update)
|
|
147
|
+
def subscriptions
|
|
148
|
+
read
|
|
149
|
+
rescue Corrupt
|
|
150
|
+
[]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The subscriptions for +origin+ (the ones chi web sends to now).
|
|
154
|
+
def for_origin(origin) = subscriptions.select { |sub| sub.origin == origin }
|
|
155
|
+
|
|
156
|
+
def find(id) = subscriptions.find { |sub| sub.id == id }
|
|
157
|
+
|
|
158
|
+
# Adds the subscription a page's pushManager.subscribe gave (its
|
|
159
|
+
# JSON: endpoint, keys {p256dh, auth}); the same endpoint again
|
|
160
|
+
# replaces the old entry (a device subscribing again).
|
|
161
|
+
# @return [Sub]
|
|
162
|
+
# @raise [Invalid] one message per refusal
|
|
163
|
+
def add(subscription, origin:, label:)
|
|
164
|
+
endpoint, p256dh, auth = validate(subscription)
|
|
165
|
+
at = stamp(now)
|
|
166
|
+
sub = Sub.new(id: Store.short_id(endpoint), endpoint: endpoint, p256dh: p256dh, auth: auth, origin: origin,
|
|
167
|
+
label: label.to_s[0, LABEL_MAX], created_at: at, last_ok_at: nil, last_error: nil,
|
|
168
|
+
prefs: { "done" => false })
|
|
169
|
+
update do |subs|
|
|
170
|
+
kept = subs.reject { |old| old.endpoint == endpoint }
|
|
171
|
+
raise Invalid, "chi web already pushes to #{MAX_SUBS} devices: turn the bell off on one first" if kept.size >= MAX_SUBS
|
|
172
|
+
|
|
173
|
+
[kept + [sub], sub]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Removes the subscription with this short id or endpoint.
|
|
178
|
+
# @return [Sub, nil] the one removed
|
|
179
|
+
def remove(id_or_endpoint)
|
|
180
|
+
update do |subs|
|
|
181
|
+
gone = subs.find { |sub| sub.id == id_or_endpoint || sub.endpoint == id_or_endpoint }
|
|
182
|
+
[subs - [gone], gone]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Records a delivery: +ok+ true stamps last_ok_at (and clears the
|
|
187
|
+
# error), else +error+ (a short word: "410", "timeout") is kept.
|
|
188
|
+
def mark(id, ok: false, error: nil)
|
|
189
|
+
update do |subs|
|
|
190
|
+
subs = subs.map do |sub|
|
|
191
|
+
next sub unless sub.id == id
|
|
192
|
+
|
|
193
|
+
ok ? sub.with(last_ok_at: stamp(now), last_error: nil) : sub.with(last_error: error.to_s[0, 80])
|
|
194
|
+
end
|
|
195
|
+
[subs, nil]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def now = @clock.call
|
|
202
|
+
|
|
203
|
+
def stamp(time) = time.utc.iso8601
|
|
204
|
+
|
|
205
|
+
# @return [Array(String, String, String)] endpoint, p256dh, auth
|
|
206
|
+
def validate(subscription)
|
|
207
|
+
keys = subscription["keys"] if subscription.is_a?(Hash)
|
|
208
|
+
endpoint = subscription["endpoint"] if subscription.is_a?(Hash)
|
|
209
|
+
unless endpoint.is_a?(String) && keys.is_a?(Hash) && keys["p256dh"].is_a?(String) && keys["auth"].is_a?(String)
|
|
210
|
+
raise Invalid, "a push subscription needs an endpoint and keys (p256dh, auth)"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
check_endpoint(endpoint)
|
|
214
|
+
p256dh = decoded(keys["p256dh"])
|
|
215
|
+
unless p256dh&.bytesize == 65 && p256dh.getbyte(0) == 4
|
|
216
|
+
raise Invalid, "a push subscription's p256dh must be a P-256 public key (65 bytes, base64url)"
|
|
217
|
+
end
|
|
218
|
+
raise Invalid, "a push subscription's auth must be 16 bytes (base64url)" unless decoded(keys["auth"])&.bytesize == 16
|
|
219
|
+
|
|
220
|
+
[endpoint, keys["p256dh"], keys["auth"]]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def check_endpoint(endpoint)
|
|
224
|
+
uri = begin
|
|
225
|
+
URI.parse(endpoint)
|
|
226
|
+
rescue URI::InvalidURIError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
if endpoint.bytesize > ENDPOINT_MAX || uri.nil? || uri.scheme != "https" || uri.host.to_s.empty? ||
|
|
230
|
+
uri.userinfo || uri.port != 443
|
|
231
|
+
raise Invalid, "a push endpoint must be an https URL on port 443"
|
|
232
|
+
end
|
|
233
|
+
return if service?(uri.host.downcase)
|
|
234
|
+
|
|
235
|
+
raise Invalid, "chi web only pushes to the browsers' push services, not #{uri.host[0, 80]}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def service?(host)
|
|
239
|
+
@services.any? { |entry| entry.start_with?(".") ? host.end_with?(entry) && host.length > entry.length : host == entry }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def decoded(text)
|
|
243
|
+
Crypto.unb64u(text)
|
|
244
|
+
rescue ArgumentError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @raise [Corrupt]
|
|
249
|
+
def read
|
|
250
|
+
data = begin
|
|
251
|
+
JSON.parse(File.read(subs_path))
|
|
252
|
+
rescue Errno::ENOENT
|
|
253
|
+
return []
|
|
254
|
+
rescue JSON::ParserError
|
|
255
|
+
raise Corrupt.new(subs_path, "not JSON")
|
|
256
|
+
end
|
|
257
|
+
rows = data["subscriptions"] if data.is_a?(Hash)
|
|
258
|
+
raise Corrupt.new(subs_path, "no subscriptions list") unless rows.is_a?(Array) && rows.all?(Hash)
|
|
259
|
+
|
|
260
|
+
rows.map { |row| Sub.from_store(row) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Read, change and write subscriptions.json under the lock. The block
|
|
264
|
+
# takes the subscriptions and returns [new subscriptions, result].
|
|
265
|
+
def update
|
|
266
|
+
locked do
|
|
267
|
+
subs, result = yield(read)
|
|
268
|
+
body = JSON.pretty_generate({ "version" => 1, "subscriptions" => subs.map(&:to_store) })
|
|
269
|
+
AtomicFile.write(subs_path, "#{body}\n", perm: 0o600)
|
|
270
|
+
result
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def locked
|
|
275
|
+
FileUtils.mkdir_p(@dir, mode: 0o700)
|
|
276
|
+
File.chmod(0o700, @dir)
|
|
277
|
+
File.open(File.join(@dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
278
|
+
lock.flock(File::LOCK_EX)
|
|
279
|
+
yield
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "push/crypto"
|
|
4
|
+
require_relative "push/store"
|
|
5
|
+
require_relative "push/client"
|
|
6
|
+
require_relative "push/attention"
|
|
7
|
+
require_relative "push/notifier"
|
|
8
|
+
require_relative "../config"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module Web
|
|
12
|
+
# Web Push: chi web reaches the owner's phone (a Home Screen app that
|
|
13
|
+
# turned the bell on) when a session waits on an approval or a question.
|
|
14
|
+
# It needs an https public origin (web.public_url or chi web --tunnel
|
|
15
|
+
# tailscale) and web.push.enabled (on by default); without a subscribed
|
|
16
|
+
# device it does nothing. See docs/configuration.md (Phone
|
|
17
|
+
# notifications) and docs/internals/web-push.md.
|
|
18
|
+
module Push
|
|
19
|
+
# chi web won't start with push as configured: a web.push.contact
|
|
20
|
+
# Apple would refuse, or a key it can't read.
|
|
21
|
+
class Refused < StandardError; end
|
|
22
|
+
|
|
23
|
+
# What chi web's push routes and its Notifier share for one run: the
|
|
24
|
+
# store, the client that signs with its key, and the public origin
|
|
25
|
+
# the devices subscribe on.
|
|
26
|
+
Runtime = Data.define(:store, :client, :origin)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Push runs for +public_url+ (nil: none) when web.push.enabled.
|
|
31
|
+
def active?(public_url) = !public_url.nil? && Config.get("web.push.enabled") != false
|
|
32
|
+
|
|
33
|
+
# The VAPID sub: web.push.contact, else the public origin.
|
|
34
|
+
# @raise [Refused] a contact that isn't a mailto: or https URL
|
|
35
|
+
def contact(origin)
|
|
36
|
+
given = Config.get("web.push.contact").to_s.strip
|
|
37
|
+
return origin if given.empty?
|
|
38
|
+
return given if Crypto.valid_sub?(given)
|
|
39
|
+
|
|
40
|
+
raise Refused, "web.push.contact must be a mailto: address or an https URL (the push services refuse " \
|
|
41
|
+
"anything else), not #{given[0, 80]}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Server.launch's check, before a tunnel goes up: the contact, the
|
|
45
|
+
# store's permissions and its key (made the first time).
|
|
46
|
+
# @raise [Refused, Store::Insecure]
|
|
47
|
+
def preflight!(public_url, store: Store.new)
|
|
48
|
+
return unless active?(public_url)
|
|
49
|
+
|
|
50
|
+
contact(public_url.origin)
|
|
51
|
+
store.check_permissions!
|
|
52
|
+
load_key(store)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @raise [Refused] vapid.pem is there but isn't a key
|
|
56
|
+
def load_key(store)
|
|
57
|
+
store.vapid_key
|
|
58
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
59
|
+
raise Refused, "#{store.key_path} isn't a key chi can read (#{e.message[0, 80]}): move it aside and turn " \
|
|
60
|
+
"the bell on again on each device"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# This run's Runtime, nil when push is off.
|
|
64
|
+
def runtime(public_url, state_dir: nil)
|
|
65
|
+
return nil unless active?(public_url)
|
|
66
|
+
|
|
67
|
+
store = Store.new(Store.dir(state_dir: state_dir))
|
|
68
|
+
Runtime.new(store: store, client: Client.new(key: load_key(store), contact: contact(public_url.origin)),
|
|
69
|
+
origin: public_url.origin)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "push"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# The owner's Web Push routes (App::ROUTES, all :owner rows: a
|
|
9
|
+
# participant gets no pushes): what the page's bell asks (GET /api/push),
|
|
10
|
+
# subscribing this device and unsubscribing it, and a test push.
|
|
11
|
+
# Included in App: its helpers (json_response, error_response,
|
|
12
|
+
# parse_json, request_body) and @push (a Push::Runtime, nil when push is
|
|
13
|
+
# off: no https public_url, or web.push.enabled false) answer. See
|
|
14
|
+
# docs/internals/web-push.md.
|
|
15
|
+
module PushRoutes
|
|
16
|
+
PUSH_BODY_LIMIT = 8192
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# {enabled, public_key, origin, this}: +this+ is the device whose
|
|
21
|
+
# endpoint the page sends as ?endpoint= (its pushManager's), nil when
|
|
22
|
+
# this store doesn't have it for this origin.
|
|
23
|
+
def handle_push_info(req)
|
|
24
|
+
return json_response(200, { enabled: false, public_key: nil, origin: nil, this: nil }) unless @push
|
|
25
|
+
|
|
26
|
+
endpoint = req.params["endpoint"].to_s
|
|
27
|
+
this = endpoint.empty? ? nil : @push.store.for_origin(@push.origin).find { |sub| sub.endpoint == endpoint }
|
|
28
|
+
json_response(200, { enabled: true, public_key: Push::Crypto.public_key_b64(@push.store.vapid_key),
|
|
29
|
+
origin: @push.origin, this: this && { id: this.id, label: this.label } })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# {subscription: {endpoint, keys: {p256dh, auth}}} → 201 {id, label}.
|
|
33
|
+
def handle_push_subscribe(req)
|
|
34
|
+
return push_off unless @push
|
|
35
|
+
|
|
36
|
+
body = push_body(req)
|
|
37
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req))
|
|
40
|
+
Log.info(:web, "push_subscribed", device: sub.id)
|
|
41
|
+
json_response(201, { id: sub.id, label: sub.label })
|
|
42
|
+
rescue Push::Store::Invalid => e
|
|
43
|
+
error_response(400, "invalid_subscription", e.message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# {endpoint} → 204: this device's bell turned off.
|
|
47
|
+
def handle_push_unsubscribe(req)
|
|
48
|
+
return push_off unless @push
|
|
49
|
+
|
|
50
|
+
body = push_body(req)
|
|
51
|
+
endpoint = body["endpoint"] if body.is_a?(Hash)
|
|
52
|
+
return error_response(400, "missing_fields", "endpoint is required") unless endpoint.is_a?(String) && !endpoint.empty?
|
|
53
|
+
|
|
54
|
+
gone = @push.store.remove(endpoint)
|
|
55
|
+
Log.info(:web, "push_unsubscribed", device: gone.id) if gone
|
|
56
|
+
[204, { "Cache-Control" => "no-store" }, []]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# {endpoint?} → a test push to that device (or every one on this
|
|
60
|
+
# origin), answered with each device's result.
|
|
61
|
+
def handle_push_test(req)
|
|
62
|
+
return push_off unless @push
|
|
63
|
+
|
|
64
|
+
body = push_body(req)
|
|
65
|
+
endpoint = body["endpoint"] if body.is_a?(Hash)
|
|
66
|
+
subs = @push.store.for_origin(@push.origin)
|
|
67
|
+
subs = subs.select { |sub| sub.endpoint == endpoint } if endpoint.is_a?(String) && !endpoint.empty?
|
|
68
|
+
payload = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }
|
|
69
|
+
results = subs.map do |sub|
|
|
70
|
+
result = @push.client.deliver(sub, payload, ttl: 600, urgency: "high")
|
|
71
|
+
Log.info(:web, "push_test", device: sub.id, code: result.code, ms: result.ms)
|
|
72
|
+
@push.store.remove(sub.id) if result.status == :gone
|
|
73
|
+
{ id: sub.id, label: sub.label, status: result.status.to_s, code: result.code, ms: result.ms }
|
|
74
|
+
end
|
|
75
|
+
json_response(200, { devices: results })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def push_off
|
|
79
|
+
error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
|
|
80
|
+
"and web.push.enabled")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def push_body(req) = parse_json(request_body(req, PUSH_BODY_LIMIT))
|
|
84
|
+
|
|
85
|
+
# chi's own name for the device (a subscription carries none): its
|
|
86
|
+
# class from the User-Agent (iOS's is frozen, so it is generic) and
|
|
87
|
+
# the date.
|
|
88
|
+
def device_label(req)
|
|
89
|
+
agent = req.get_header("HTTP_USER_AGENT").to_s
|
|
90
|
+
kind = if agent.include?("iPhone") then "iPhone"
|
|
91
|
+
elsif agent.include?("iPad") then "iPad"
|
|
92
|
+
elsif agent.include?("Macintosh") then "Mac"
|
|
93
|
+
else "Browser"
|
|
94
|
+
end
|
|
95
|
+
"#{kind} · #{Time.now.strftime("%Y-%m-%d")}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -12,6 +12,7 @@ require_relative "lan"
|
|
|
12
12
|
require_relative "link_codes"
|
|
13
13
|
require_relative "proxy_gate"
|
|
14
14
|
require_relative "public_url"
|
|
15
|
+
require_relative "push"
|
|
15
16
|
require_relative "qr"
|
|
16
17
|
require_relative "running"
|
|
17
18
|
require_relative "session_hub"
|
|
@@ -121,6 +122,10 @@ module Samagotchi
|
|
|
121
122
|
end
|
|
122
123
|
# The participants' grants: never served from a store others can read.
|
|
123
124
|
Grants.check_permissions!(Grants.path)
|
|
125
|
+
# Web Push (with an https public_url): a contact the push services
|
|
126
|
+
# would refuse, or a key store others can read, stops it here,
|
|
127
|
+
# before any tunnel goes up.
|
|
128
|
+
Push.preflight!(public_url)
|
|
124
129
|
options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
|
|
125
130
|
annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
|
|
126
131
|
public_url: public_url }
|
|
@@ -131,7 +136,7 @@ module Samagotchi
|
|
|
131
136
|
end
|
|
132
137
|
started ? 0 : 1
|
|
133
138
|
end
|
|
134
|
-
rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused => e
|
|
139
|
+
rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused, Push::Refused, Push::Store::Insecure => e
|
|
135
140
|
warn "Error: #{e.message}"
|
|
136
141
|
1
|
|
137
142
|
rescue Interrupt
|
|
@@ -198,9 +203,10 @@ module Samagotchi
|
|
|
198
203
|
Token.load_or_create(token_path) if lan || public_url
|
|
199
204
|
lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
|
|
200
205
|
public_token = Token::Source.new(token_path) if public_url
|
|
206
|
+
push = push_runtime(public_url, state_dir)
|
|
201
207
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
|
|
202
208
|
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
|
|
203
|
-
public_url: public_url, public_token: public_token)
|
|
209
|
+
public_url: public_url, public_token: public_token, push: push)
|
|
204
210
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
205
211
|
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
206
212
|
if public_url
|
|
@@ -209,6 +215,13 @@ module Samagotchi
|
|
|
209
215
|
end
|
|
210
216
|
# Subscribed before the first scan, which makes the first check.
|
|
211
217
|
hub.subscribe(method(:on_hub_event))
|
|
218
|
+
if push
|
|
219
|
+
# The Notifier seeds from the hub's snapshot, which is empty before
|
|
220
|
+
# a scan: one now, so what is already open never pushes.
|
|
221
|
+
hub.scan
|
|
222
|
+
notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin).start(hub)
|
|
223
|
+
Samagotchi::Log.info(:web, "push", devices: push.store.for_origin(push.origin).size)
|
|
224
|
+
end
|
|
212
225
|
hub.start
|
|
213
226
|
# Said once the port is bound: a second chi web racing for it gets
|
|
214
227
|
# the in-use line instead.
|
|
@@ -249,12 +262,23 @@ module Samagotchi
|
|
|
249
262
|
ensure
|
|
250
263
|
Running.clear(path: running_path) if running_path
|
|
251
264
|
config_restart_unsubscribe&.call
|
|
265
|
+
notifier&.stop
|
|
252
266
|
hub&.stop
|
|
253
267
|
Samagotchi::Log.info(:web, "stop") if app
|
|
254
268
|
end
|
|
255
269
|
|
|
256
270
|
class LanListenError < StandardError; end
|
|
257
271
|
|
|
272
|
+
# This run's Web Push (Push.runtime), nil when off. launch checked it
|
|
273
|
+
# (Push.preflight!); a store that broke since leaves chi web running
|
|
274
|
+
# without pushes, said in one line.
|
|
275
|
+
def self.push_runtime(public_url, state_dir)
|
|
276
|
+
Push.runtime(public_url, state_dir: state_dir)
|
|
277
|
+
rescue Push::Refused, Push::Store::Insecure, SystemCallError => e
|
|
278
|
+
Samagotchi::Log.warn(:web, "push_off", echo: "Warning: #{e.message}; phone notifications are off")
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
258
282
|
# Where this chi web listens, and its public_url (config's or the
|
|
259
283
|
# tunnel's), for chi share's link (Web::Running); a write that fails
|
|
260
284
|
# leaves the server running and the readers on web.port and config.
|