livechat 0.10.1 → 1.1.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/AGENTS.md +44 -5
- data/CHANGELOG.md +58 -0
- data/README.md +134 -4
- data/Rakefile +9 -1
- data/SECURITY.md +71 -0
- data/app/controllers/concerns/livechat/request_context.rb +20 -0
- data/app/controllers/livechat/application_controller.rb +9 -0
- data/app/controllers/livechat/attachments_controller.rb +66 -7
- data/app/controllers/livechat/visitor_controller.rb +3 -2
- data/app/controllers/livechat/widgets_controller.rb +29 -0
- data/app/models/livechat/conversation.rb +14 -0
- data/app/models/livechat/message.rb +2 -2
- data/config/routes.rb +1 -0
- data/lib/livechat/attachment_policy.rb +27 -0
- data/lib/livechat/configuration.rb +22 -2
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.rb +42 -14
- data/{app/helpers → lib}/livechat/widget_helper.rb +6 -13
- data/lib/livechat.rb +45 -0
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5968fc9db00037cc2e6330c09e1de36d1f7089bb9649972fd6371ae0349248a
|
|
4
|
+
data.tar.gz: 193702a94032278baa4d1c5162b8e5f03aa309803b760edb1036ddf1bfbe222f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3a7a2d524aacbfe4287e89ba4fd3c4714a94df8e00731fd00d375ab1a0c658b36b5272860a9bc56d92401f15d4dff0804987f7de5d0e421825f939e0fc42661
|
|
7
|
+
data.tar.gz: 81a4123e0a17426d009c68f6f3f31199e46e9d2ed6a6b837eea2ffe4ed5d36361b9cc671989c8a4ffb6b1a6ededd7d246b92727ab656af25b6ede80779b6f284
|
data/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ Instructions for coding agents. Two audiences:
|
|
|
5
5
|
- **[Installing livechat into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add support chat, live chat, or an in-app inbox.
|
|
6
6
|
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
7
|
|
|
8
|
-
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for file attachments. **No Redis and no Action Cable** — the transport is polling unless you opt in.
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1 and < 9. Active Storage only for file attachments. **No Redis and no Action Cable** — the transport is polling unless you opt in.
|
|
9
9
|
|
|
10
10
|
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show livechat)/AGENTS.md"`.
|
|
11
11
|
|
|
@@ -104,9 +104,39 @@ Leave it off unless the app already has Action Cable working. Polling is not a d
|
|
|
104
104
|
|
|
105
105
|
`config.attach_files` is on by default but **silently inert without Active Storage** in the host app (`rails active_storage:install`) — the widget keeps working, just without a paperclip. Caps: `max_attachments` (5), `max_attachment_size` (10 MB), `allowed_attachment_types` (nil = any). Files are served through the engine at `/livechat/attachments/:id`, gated per request — never a public blob URL. Do not build your own blob links.
|
|
106
106
|
|
|
107
|
+
Every authorized agent can read every conversation; visitor reads are scoped to
|
|
108
|
+
the signed-in id or guest cookie. There is no tenant-isolated agent inbox in
|
|
109
|
+
1.x. No retention job runs automatically. Hosts should schedule their own
|
|
110
|
+
cutoff and use the destroy lifecycle so messages and attachments follow it:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Livechat::Conversation.purge_resolved(older_than: 90.days.ago)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For an account-erasure request, destroy that user's conversations with
|
|
117
|
+
`where(visitor_id: user.id.to_s).find_each(&:destroy!)`. Do not use
|
|
118
|
+
`delete_all` when attachments must be purged. Confirm storage jobs, backups,
|
|
119
|
+
email notifications and hook destinations separately. The content-type
|
|
120
|
+
allowlist is not malware scanning.
|
|
121
|
+
|
|
122
|
+
### Embedding on a static site
|
|
123
|
+
|
|
124
|
+
A marketing site that proxies the mount path through to Rails gets the widget from one tag:
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<script src="/livechat/embed.js" defer></script>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Add `?locale=de` for a translated page. Two requirements, and both are load-bearing:
|
|
131
|
+
|
|
132
|
+
1. **Same origin.** The widget sends `credentials: "same-origin"`, so a cross-origin `<script src>` loads and then silently fails to carry the visitor cookie. Proxy `/livechat/*` from the static site to Rails; do not point the tag at another host.
|
|
133
|
+
2. **`config.allowed_embed_origins = ["https://example.com"]`.** A static page cannot mint a CSRF token, so a request from a listed origin is exempted. Safe because `Origin` is browser-set and unforgeable by page script, it covers only the visitor endpoints, and a request with no `Origin` is never treated as embedded.
|
|
134
|
+
|
|
135
|
+
Give that surface its own wording with a callable — `config.greeting = ->(request) { ... }` keyed on `request.origin`.
|
|
136
|
+
|
|
107
137
|
### Do not
|
|
108
138
|
|
|
109
|
-
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `livechat_tag` renders what is needed, and the engine serves the code with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
139
|
+
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `livechat_tag` renders what is needed (on a page Rails does not render, use `embed.js` above — still not a copy), and the engine serves the code with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
110
140
|
- **Do not rebuild the inbox, and do not edit views inside the gem.** To put it inside an admin you already have, set `config.base_controller_class = "Admin::BaseController"` — it inherits that controller's layout, helpers, authentication and request context. For the shell alone, `config.agent_layout = "admin/application"`. Both work with nothing else wired up: the inbox's own assets are declared by its views, not the layout.
|
|
111
141
|
- **Do not add an Action Cable mount to "make chat realtime"** unless you also set `config.action_cable = true`. Polling is the default and is not broken.
|
|
112
142
|
- **Do not set config outside the initializer.** `rate_limit` in particular is read when the controller class loads; assigning config per-request mutates it process-wide.
|
|
@@ -124,9 +154,10 @@ Everything is optional; a fresh install works with zero config. Full list with c
|
|
|
124
154
|
| `current_user` | `nil` | Receives the request; nil means guest-by-cookie |
|
|
125
155
|
| `visitor_label`, `agent_label` | name/email/to_s | Receive the user |
|
|
126
156
|
| `agent_display_name` | the label unchanged | Receives the label; return "Support team" to keep agents anonymous |
|
|
127
|
-
| `app_name`, `greeting`, `reply_time_text`, `launcher_label` | localized defaults | Widget copy |
|
|
128
|
-
| `avatar_url`, `accent_color` | `nil` | Header avatar (URL or callable) and brand hex |
|
|
129
|
-
| `show_launcher` | `true` | `false` = open only from your own elements |
|
|
157
|
+
| `app_name`, `greeting`, `reply_time_text`, `launcher_label` | localized defaults | Widget copy. All but `app_name` also take a callable receiving the request |
|
|
158
|
+
| `avatar_url`, `accent_color` | `nil` | Header avatar (URL or callable) and brand hex (also callable) |
|
|
159
|
+
| `show_launcher` | `true` | `false` = open only from your own elements. Also takes a callable |
|
|
160
|
+
| `allowed_embed_origins` | `[]` | Origins that may embed the widget on a page this app does not render |
|
|
130
161
|
| `mailer_from` | `nil` | **Required for any email at all** |
|
|
131
162
|
| `agent_emails` | `nil` | Array or callable |
|
|
132
163
|
| `attach_files` | `true` | Needs Active Storage; inert without it |
|
|
@@ -152,6 +183,8 @@ Turbo Drive and strict nonce-based CSP work out of the box. 26 locales ship with
|
|
|
152
183
|
| Messages only appear on refresh | Expected: polling is the default. `config.action_cable = true` (with `/cable` mounted) for push |
|
|
153
184
|
| No attachment button | Active Storage not installed, or `attach_files = false` |
|
|
154
185
|
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request` |
|
|
186
|
+
| Widget loads on a static site but every message 422s | The embedding origin is not in `allowed_embed_origins`. A static page has no CSRF token |
|
|
187
|
+
| Widget on a static site never finds its thread | It is pointed at another origin. The widget sends `credentials: "same-origin"` — proxy the mount path instead of linking across hosts |
|
|
155
188
|
|
|
156
189
|
---
|
|
157
190
|
|
|
@@ -161,8 +194,14 @@ Turbo Drive and strict nonce-based CSP work out of the box. 26 locales ship with
|
|
|
161
194
|
|
|
162
195
|
## Working on the gem itself
|
|
163
196
|
|
|
197
|
+
The development toolchain is pinned to Ruby 4.0.5 in `mise.toml`. Do not use
|
|
198
|
+
macOS's `/usr/bin/ruby`. In shells, hooks, or automation where mise activation
|
|
199
|
+
is uncertain, explicitly prepend the configured Ruby, for example
|
|
200
|
+
`PATH="$(mise where ruby)/bin:$PATH" bundle exec rake test`.
|
|
201
|
+
|
|
164
202
|
```bash
|
|
165
203
|
bundle exec rake test # minitest, dummy app under test/dummy
|
|
204
|
+
bundle exec rake test:system # real headless-Chrome visitor/agent loop
|
|
166
205
|
bundle exec rubocop # must be clean
|
|
167
206
|
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
168
207
|
```
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 - 2026-09-25
|
|
4
|
+
|
|
5
|
+
- **The widget can now live on a site this app does not render.** A static
|
|
6
|
+
marketing site that proxies the mount path through to Rails loads the whole
|
|
7
|
+
widget from one tag — `<script src="/livechat/embed.js" defer>` — and never
|
|
8
|
+
reproduces the config block itself. `?locale=` picks the copy. The response is
|
|
9
|
+
never cached; the widget code it pulls in stays immutably cached as before.
|
|
10
|
+
- Added `config.allowed_embed_origins`. A page served from a listed origin
|
|
11
|
+
cannot carry a Rails CSRF token, so a matching request is exempted from
|
|
12
|
+
forgery protection. The exemption rests on the `Origin` header, which the
|
|
13
|
+
browser sets and page script cannot forge; it covers only the widget's own
|
|
14
|
+
visitor endpoints, never the inbox; and a request that sends no `Origin` at
|
|
15
|
+
all — curl, a server-side client — is never treated as embedded.
|
|
16
|
+
- Added `Livechat.public_paths`: the endpoints that answer with JavaScript, JSON
|
|
17
|
+
or a file and need no Rails session. Hosts that mint a CSP nonce (or anything
|
|
18
|
+
else) from the session per request can skip these, rather than handing a
|
|
19
|
+
cookie to every visitor who only ever loaded the page.
|
|
20
|
+
- `greeting`, `reply_time_text`, `launcher_label`, `show_launcher` and
|
|
21
|
+
`accent_color` each accept a callable receiving the request, the way
|
|
22
|
+
`avatar_url` already did. One app serving both an in-app widget and an
|
|
23
|
+
embedded one can greet each surface differently, and show the bubble on one
|
|
24
|
+
but not the other.
|
|
25
|
+
|
|
26
|
+
## 1.0.0 - 2026-08-11
|
|
27
|
+
|
|
28
|
+
- **The 1.x record names are settled.** `Livechat::Conversation` and
|
|
29
|
+
`Livechat::Message` are now documented as stable model constants rather than
|
|
30
|
+
being collapsed into a generic `Post`; the distinction preserves the thread
|
|
31
|
+
aggregate and the messages it owns.
|
|
32
|
+
- **A real Chrome test now proves the core support loop:** a visitor opens the
|
|
33
|
+
widget and sends a message, an agent reads and answers it in the inbox, and
|
|
34
|
+
the same visitor sees the reply back in the widget. Capybara and Selenium are
|
|
35
|
+
development/test dependencies only.
|
|
36
|
+
- **Attachments stay behind the conversation gate.** Full and byte-range data
|
|
37
|
+
now streams directly with a private, `no-store` cache policy after the
|
|
38
|
+
request is authorized. The URL is unchanged; audio playback gains seeking,
|
|
39
|
+
and no reusable Active Storage service URL leaves the engine.
|
|
40
|
+
- Added `Livechat::Conversation.purge_resolved(older_than:)` for explicit host
|
|
41
|
+
retention jobs. It destroys only resolved conversations older than the
|
|
42
|
+
supplied cutoff, cascades through messages and the configured Active Storage
|
|
43
|
+
lifecycle, and returns the number destroyed. Nothing is deleted
|
|
44
|
+
automatically.
|
|
45
|
+
- Added a security policy documenting stored visitor/message data, the shared
|
|
46
|
+
agent-inbox boundary, attachment risks, notifications, deletion, retention,
|
|
47
|
+
and private vulnerability reporting. Submitted page URLs now accept only
|
|
48
|
+
HTTP(S) and drop credentials, query strings, and fragments before storage,
|
|
49
|
+
keeping tokens out of the database and notification emails.
|
|
50
|
+
- Hosts that load Action View from an initializer boot normally. The widget
|
|
51
|
+
helper is loaded from `lib` before the engine registers its Action View hook;
|
|
52
|
+
`livechat_tag` and `livechat_button` are unchanged.
|
|
53
|
+
- Supported combinations are Ruby 3.2 through 4.0 and Rails 7.1 through 8.1;
|
|
54
|
+
Rails now has a `< 9` upper bound. Release validation runs the browser test,
|
|
55
|
+
lint and JavaScript checks, builds the gem, verifies RubyGems indexing, and
|
|
56
|
+
creates the GitHub Release.
|
|
57
|
+
- Existing 0.x installs need no migration, initializer change, constant alias,
|
|
58
|
+
or model rename. The new Rails upper bound, query-free stored page URLs, and
|
|
59
|
+
direct attachment streaming behavior are the compatibility costs.
|
|
60
|
+
|
|
3
61
|
## 0.10.1
|
|
4
62
|
|
|
5
63
|
- **The demo inbox is now a live setup walkthrough.** The open conversation
|
data/README.md
CHANGED
|
@@ -50,7 +50,7 @@ demo messages instead of duplicating conversations.
|
|
|
50
50
|
> The inbox defaults to **development only**. Set `authorize_agent` before you
|
|
51
51
|
> deploy — see [Configure](#configure).
|
|
52
52
|
|
|
53
|
-
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want file attachments.
|
|
53
|
+
Ruby >= 3.2 · Rails >= 7.1, < 9 · Active Storage only if you want file attachments.
|
|
54
54
|
|
|
55
55
|
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
56
56
|
steps in the order an agent needs them, plus the gates it tends to get wrong and
|
|
@@ -136,6 +136,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
136
136
|
| `avatar_url` | `nil` | Customer-facing avatar in the widget header; URL or per-request callable |
|
|
137
137
|
| `accent_color` | `nil` | One hex restyles launcher, header, bubbles, send button |
|
|
138
138
|
| `show_launcher` | `true` | `false` hides the bubble — bring your own entry point |
|
|
139
|
+
| `allowed_embed_origins` | `[]` | Origins allowed to embed the widget on a page this app doesn't render |
|
|
139
140
|
| `visitor_label` | name, else email | How a visitor is labelled in the inbox |
|
|
140
141
|
| `agent_label` | name, else email | How an agent is labelled internally |
|
|
141
142
|
| `agent_display_name` | the full label | What **visitors** see — trim it or anonymise it |
|
|
@@ -167,7 +168,9 @@ Livechat.configure do |config|
|
|
|
167
168
|
end
|
|
168
169
|
```
|
|
169
170
|
|
|
170
|
-
`avatar_url`
|
|
171
|
+
`avatar_url`, `greeting`, `reply_time_text`, `launcher_label`, `show_launcher`
|
|
172
|
+
and `accent_color` each take a callable receiving the request, so one app can
|
|
173
|
+
brand or word the widget per tenant — or per surface:
|
|
171
174
|
|
|
172
175
|
```ruby
|
|
173
176
|
config.avatar_url = ->(request) { request.env["current_account"]&.support_avatar_url }
|
|
@@ -228,10 +231,11 @@ bin/rails active_storage:install && bin/rails db:migrate
|
|
|
228
231
|
```
|
|
229
232
|
|
|
230
233
|
Visitors get a paperclip in the composer; agents get a file field on the reply
|
|
231
|
-
form.
|
|
234
|
+
form. Safe image formats render inline, other files as download links. Every file is served
|
|
232
235
|
through the engine's own route and gated the same way the chat is — an agent,
|
|
233
236
|
or the visitor who owns that conversation — so nothing leaks through a
|
|
234
|
-
guessable or long-lived blob URL.
|
|
237
|
+
guessable or long-lived blob URL. Responses are private and `no-store`; audio
|
|
238
|
+
supports byte ranges for playback and seeking.
|
|
235
239
|
|
|
236
240
|
Where Active Storage isn't installed, the widget quietly stays text-only.
|
|
237
241
|
|
|
@@ -244,6 +248,98 @@ config.storage_service = :livechat_uploads
|
|
|
244
248
|
|
|
245
249
|
</details>
|
|
246
250
|
|
|
251
|
+
## On a site this app doesn't render
|
|
252
|
+
|
|
253
|
+
Marketing sites are usually static and live on their own host, which is exactly
|
|
254
|
+
where people ask presales questions. The widget can go there too, with one tag:
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<script src="/livechat/embed.js" defer></script>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`embed.js` carries the config block the layout helper would have rendered and
|
|
261
|
+
pulls in the widget, so the static page never reproduces engine markup that a
|
|
262
|
+
future release might change. Add `?locale=de` for a translated site.
|
|
263
|
+
|
|
264
|
+
Two things have to be true.
|
|
265
|
+
|
|
266
|
+
**The mount path must be same-origin with that site.** The widget sends
|
|
267
|
+
`credentials: "same-origin"`, so a cross-origin script tag would load and then
|
|
268
|
+
quietly fail to carry the visitor's cookie. Proxy `/livechat/*` from the static
|
|
269
|
+
site through to Rails — one rule on a CDN, one `location` block in nginx — and
|
|
270
|
+
the browser sees a single origin.
|
|
271
|
+
|
|
272
|
+
**The embedding origin must be allowlisted**, because a static page cannot mint
|
|
273
|
+
a CSRF token:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
config.allowed_embed_origins = ["https://example.com"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`Livechat.public_paths` lists the endpoints that need no Rails session — the
|
|
280
|
+
widget script, the embed bootstrap, the visitor API and attachments. If your app
|
|
281
|
+
touches the session on every request (a CSP nonce generator usually does), skip
|
|
282
|
+
them there, or every visitor who merely loads a page leaves with a cookie:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
config.content_security_policy_nonce_generator = lambda do |request|
|
|
286
|
+
next if Livechat.public_paths.any? { |path| request.path.start_with?(path) }
|
|
287
|
+
request.session[:csp_nonce] ||= SecureRandom.hex(16)
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A request from a listed origin skips forgery protection. What makes that safe is
|
|
292
|
+
that `Origin` is set by the browser and cannot be forged by page script, so
|
|
293
|
+
listing an origin grants nothing to any other site; the exemption covers only
|
|
294
|
+
the widget's visitor endpoints and never the inbox; and a request with no
|
|
295
|
+
`Origin` header at all is never treated as embedded, so a non-browser client
|
|
296
|
+
gains nothing.
|
|
297
|
+
|
|
298
|
+
Marketing visitors usually want a different greeting from signed-in users, and
|
|
299
|
+
the same app serves both:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
config.greeting = lambda do |request|
|
|
303
|
+
if request.origin == "https://example.com"
|
|
304
|
+
"Questions before you buy? Ask away."
|
|
305
|
+
else
|
|
306
|
+
"Hi! How can we help?"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Privacy, retention, and deletion
|
|
312
|
+
|
|
313
|
+
Livechat stores conversation identity and context, message bodies and
|
|
314
|
+
attribution, read/status timestamps, and optional attachments in the host
|
|
315
|
+
application. Captured page URLs are limited to HTTP(S) and stored without
|
|
316
|
+
credentials, query strings, or fragments. The engine does not send data to a
|
|
317
|
+
Livechat service, but configured notification emails and message hooks can send
|
|
318
|
+
message content, visitor context, and attachment filenames wherever the host
|
|
319
|
+
chooses.
|
|
320
|
+
|
|
321
|
+
Every authorized agent can read every conversation in this one shared inbox.
|
|
322
|
+
Visitors can read only the conversation resolved from their signed-in id or
|
|
323
|
+
guest cookie. Livechat does not provide tenant-isolated agent inboxes in 1.x;
|
|
324
|
+
do not use one mount for mutually isolated tenant support teams.
|
|
325
|
+
|
|
326
|
+
No record is deleted automatically. Delete one conversation (including its
|
|
327
|
+
messages), all conversations for a user, or resolved history past a host-chosen
|
|
328
|
+
cutoff explicitly:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Livechat::Conversation.find(id).destroy!
|
|
332
|
+
Livechat::Conversation.where(visitor_id: user.id.to_s).find_each(&:destroy!)
|
|
333
|
+
Livechat::Conversation.purge_resolved(older_than: 90.days.ago)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Use `destroy!`, not `delete_all`, when attachments must follow Rails' Active
|
|
337
|
+
Storage lifecycle. Confirm object-storage jobs, replicas, backups, notification
|
|
338
|
+
emails, hook destinations, logs, and exports separately: deleting live database
|
|
339
|
+
rows cannot retract those copies. `allowed_attachment_types` is an upload
|
|
340
|
+
allowlist, not malware scanning; choose a restrictive list and storage policy
|
|
341
|
+
for your application. See [SECURITY.md](SECURITY.md) for the full boundary.
|
|
342
|
+
|
|
247
343
|
<details>
|
|
248
344
|
<summary><b>Realtime with Action Cable</b></summary>
|
|
249
345
|
|
|
@@ -293,6 +389,39 @@ Contextual buttons are where a Rails app beats a generic support widget:
|
|
|
293
389
|
</button>
|
|
294
390
|
```
|
|
295
391
|
|
|
392
|
+
## Compatibility and public API
|
|
393
|
+
|
|
394
|
+
The following are the model and integration contracts that 1.x will keep
|
|
395
|
+
stable under semantic versioning:
|
|
396
|
+
|
|
397
|
+
- `Livechat::Conversation`, including `STATUSES`, `TYPING_TTL`, its persisted
|
|
398
|
+
fields, `messages`, `recent_first`, `for_visitor`, `claim!`, posting and
|
|
399
|
+
read/status methods, `display_name`, and `purge_resolved`.
|
|
400
|
+
- `Livechat::Message`, including `AUTHOR_TYPES`, `EVENTS`, `MAX_BODY_LENGTH`,
|
|
401
|
+
its persisted fields, optional `files`, documented scopes, author/read
|
|
402
|
+
predicates, `public_label`, `attached_files`, and `preview`.
|
|
403
|
+
- `Livechat.configure`, `mount_livechat`, `livechat_tag`, `livechat_button`,
|
|
404
|
+
`window.Livechat.open/close`, `data-livechat-open`, and
|
|
405
|
+
`data-livechat-message`.
|
|
406
|
+
- The `embed.js` endpoint under the mount path, including its `locale`
|
|
407
|
+
parameter. The markup it writes is private — embed the endpoint, not a copy
|
|
408
|
+
of its output.
|
|
409
|
+
- `config.on_visitor_message` and `config.on_agent_message`, each called with
|
|
410
|
+
the saved `Livechat::Message` documented by the initializer.
|
|
411
|
+
|
|
412
|
+
`Conversation` and `Message` are intentionally distinct and will not become a
|
|
413
|
+
generic `Post` during 1.x. Engine controllers, partials, CSS classes, generated
|
|
414
|
+
HTML, and widget implementation objects are private. Incompatible changes to
|
|
415
|
+
the public list above wait for a new major version; a deprecation normally
|
|
416
|
+
ships first.
|
|
417
|
+
|
|
418
|
+
### Upgrading from 0.x
|
|
419
|
+
|
|
420
|
+
There is no migration, initializer change, constant alias, or model rename.
|
|
421
|
+
Stored page URLs become query-free on the next visitor message, attachment
|
|
422
|
+
responses stay on the same engine URL but now stream privately instead of being
|
|
423
|
+
buffered, and Rails 9 is excluded until a compatible release is tested.
|
|
424
|
+
|
|
296
425
|
## Use cases
|
|
297
426
|
|
|
298
427
|
- SaaS apps: answer billing, onboarding and account questions from inside the app.
|
|
@@ -313,6 +442,7 @@ worth of exactly that.
|
|
|
313
442
|
|
|
314
443
|
```bash
|
|
315
444
|
bundle exec rake test
|
|
445
|
+
bundle exec rake test:system
|
|
316
446
|
bundle exec rubocop
|
|
317
447
|
```
|
|
318
448
|
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,15 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |t|
|
|
7
7
|
t.libs << 'test'
|
|
8
|
-
t.
|
|
8
|
+
t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
namespace :test do
|
|
12
|
+
desc 'Run browser (system) tests'
|
|
13
|
+
Rake::TestTask.new(:system) do |task|
|
|
14
|
+
task.libs << 'test'
|
|
15
|
+
task.test_files = FileList['test/system/**/*_test.rb']
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
|
|
11
19
|
task default: :test
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are released for the latest 1.x version. If a report also
|
|
6
|
+
affects the newest 0.x release, a backport may be published when the fix is
|
|
7
|
+
small and the affected application cannot upgrade immediately.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please do not open a public issue for a vulnerability. Use
|
|
12
|
+
[GitHub private vulnerability reporting](https://github.com/yshmarov/livechat/security/advisories/new)
|
|
13
|
+
and include the affected version, a minimal reproduction, impact, and any known
|
|
14
|
+
workaround. Do not include credentials, session cookies, visitor tokens,
|
|
15
|
+
private application URLs, customer messages, uploaded files, or production
|
|
16
|
+
database contents.
|
|
17
|
+
|
|
18
|
+
Public disclosure should wait until a fixed version is available and affected
|
|
19
|
+
users have had a reasonable opportunity to upgrade.
|
|
20
|
+
|
|
21
|
+
## Data and authorization boundary
|
|
22
|
+
|
|
23
|
+
The widget and visitor endpoints are controlled by `config.enabled`; the agent
|
|
24
|
+
inbox is separately protected by `authorize_agent` and fails closed outside
|
|
25
|
+
development until the host grants access. A visitor can read only the
|
|
26
|
+
conversation resolved from their signed-in id or opaque guest cookie. Every
|
|
27
|
+
authorized agent can read and answer every conversation in the mounted inbox.
|
|
28
|
+
Livechat 1.x does not provide tenant-isolated agent inboxes.
|
|
29
|
+
|
|
30
|
+
Conversations can store a visitor token, host user id and label, email, status,
|
|
31
|
+
query-free HTTP(S) page URL, locale, message preview, activity timestamps, and
|
|
32
|
+
record timestamps. Messages can store bodies, author type, agent id and label,
|
|
33
|
+
system events, read state, timestamps, and optional files. Page credentials,
|
|
34
|
+
query strings, fragments, executable URL schemes, and invalid URLs are not
|
|
35
|
+
stored. Message bodies are plain text and length-capped; rendering escapes
|
|
36
|
+
them rather than interpreting Markdown or HTML.
|
|
37
|
+
|
|
38
|
+
Attachments stream through the engine only after the request is authorized as
|
|
39
|
+
an agent or the owning visitor. Responses are private and `no-store`, support
|
|
40
|
+
byte ranges, and never redirect to a reusable signed blob URL. Hosts own the
|
|
41
|
+
storage service, bucket policy, backups, retention, malware controls, access
|
|
42
|
+
logs, and incident response. `allowed_attachment_types` is an application
|
|
43
|
+
allowlist, not content inspection or malware scanning.
|
|
44
|
+
|
|
45
|
+
Livechat has no vendor service and does not transmit conversation data to this
|
|
46
|
+
project. Host-configured notification email can include message content,
|
|
47
|
+
visitor context, and attachment filenames. `on_visitor_message` and
|
|
48
|
+
`on_agent_message` receive the saved message and can send it anywhere the host
|
|
49
|
+
chooses. Those email and hook destinations are part of the host's privacy and
|
|
50
|
+
retention boundary.
|
|
51
|
+
|
|
52
|
+
## Deletion and retention
|
|
53
|
+
|
|
54
|
+
Nothing is deleted automatically. Use the Active Record destroy lifecycle so
|
|
55
|
+
messages and Active Storage attachments follow their configured dependencies:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Livechat::Conversation.find(id).destroy!
|
|
59
|
+
Livechat::Conversation.where(visitor_id: user.id.to_s).find_each(&:destroy!)
|
|
60
|
+
Livechat::Conversation.purge_resolved(older_than: 90.days.ago)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`purge_resolved` removes only resolved conversations whose last activity is
|
|
64
|
+
older than the supplied cutoff and returns the number destroyed. Open threads
|
|
65
|
+
are never removed by it. Do not use `delete_all` when dependent messages and
|
|
66
|
+
attachments must be removed.
|
|
67
|
+
|
|
68
|
+
Active Storage may enqueue blob purging through the host's Active Job adapter.
|
|
69
|
+
Confirm those jobs and deletion from object storage, replicas, backups, caches,
|
|
70
|
+
notification mailboxes, hook destinations, logs, analytics, and exports;
|
|
71
|
+
deleting live database rows does not rewrite those systems.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
3
5
|
module Livechat
|
|
4
6
|
# Who is asking — visitor or agent — and the gates that answer it.
|
|
5
7
|
#
|
|
@@ -55,6 +57,24 @@ module Livechat
|
|
|
55
57
|
render json: { errors: [message] }, status: :too_many_requests
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# The widget sends window.location.href so an agent knows where the visitor
|
|
61
|
+
# got stuck. Store only a normal HTTP(S) location, never credentials,
|
|
62
|
+
# fragments, or query parameters that can carry tokens and customer data.
|
|
63
|
+
def clean_page_url(value)
|
|
64
|
+
raw = value.to_s
|
|
65
|
+
return if raw.blank? || raw.length > 2_048
|
|
66
|
+
|
|
67
|
+
uri = URI.parse(raw)
|
|
68
|
+
return unless %w[http https].include?(uri.scheme&.downcase)
|
|
69
|
+
return if uri.host.blank? || uri.userinfo.present?
|
|
70
|
+
|
|
71
|
+
uri.query = nil
|
|
72
|
+
uri.fragment = nil
|
|
73
|
+
uri.to_s.first(255)
|
|
74
|
+
rescue URI::InvalidURIError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
58
78
|
# Guests get a permanent random token — their key to the conversation
|
|
59
79
|
# across visits. Signed-in visitors are keyed by id and only need the
|
|
60
80
|
# cookie to carry a guest history into their account.
|
|
@@ -13,5 +13,14 @@ module Livechat
|
|
|
13
13
|
include RequestContext
|
|
14
14
|
|
|
15
15
|
protect_from_forgery with: :exception
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# A page on an allowlisted embed origin is static and has no Rails session
|
|
20
|
+
# to mint a token from, so the Origin header stands in for one. Only this
|
|
21
|
+
# controller's descendants are affected — the inbox verifies as usual.
|
|
22
|
+
def verified_request?
|
|
23
|
+
Livechat.embedded?(request) || super
|
|
24
|
+
end
|
|
16
25
|
end
|
|
17
26
|
end
|
|
@@ -7,6 +7,8 @@ module Livechat
|
|
|
7
7
|
# visitor whose conversation it belongs to. Streamed inline (not redirected
|
|
8
8
|
# to a signed blob URL), so no long-lived link ever escapes the gate.
|
|
9
9
|
class AttachmentsController < ApplicationController
|
|
10
|
+
include ActiveStorage::Streaming if defined?(::ActiveStorage::Streaming)
|
|
11
|
+
|
|
10
12
|
def show
|
|
11
13
|
attachment = find_attachment
|
|
12
14
|
return head :not_found unless attachment
|
|
@@ -47,15 +49,72 @@ module Livechat
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# Images and audio render inline (so the clients can show native media
|
|
50
|
-
# controls); anything else downloads.
|
|
51
|
-
#
|
|
52
|
+
# controls); anything else downloads. Responses are private and never
|
|
53
|
+
# stored, and byte ranges keep audio seek/playback working without handing
|
|
54
|
+
# out an Active Storage service URL.
|
|
52
55
|
def stream(blob)
|
|
53
56
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
58
|
+
disposition = inline?(blob) ? 'inline' : 'attachment'
|
|
59
|
+
|
|
60
|
+
if request.headers['Range'].present?
|
|
61
|
+
if inline_audio?(blob)
|
|
62
|
+
send_audio_byte_range_data(blob, request.headers['Range'])
|
|
63
|
+
else
|
|
64
|
+
send_blob_byte_range_data(blob, request.headers['Range'], disposition:)
|
|
65
|
+
end
|
|
66
|
+
elsif inline_audio?(blob)
|
|
67
|
+
stream_audio(blob)
|
|
68
|
+
else
|
|
69
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
70
|
+
response.headers['Content-Length'] = blob.byte_size.to_s
|
|
71
|
+
send_blob_stream(blob, disposition:)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Active Storage deliberately forces types outside its image/PDF allowlist
|
|
76
|
+
# to download. The widget's recorded voice notes are a small explicit
|
|
77
|
+
# exception; nosniff plus an audio Content-Type keeps them media-only.
|
|
78
|
+
def inline?(blob)
|
|
79
|
+
AttachmentPolicy.inline?(blob.content_type)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inline_audio?(blob)
|
|
83
|
+
AttachmentPolicy.audio?(blob.content_type)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def stream_audio(blob)
|
|
87
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
88
|
+
response.headers['Content-Length'] = blob.byte_size.to_s
|
|
89
|
+
send_stream(filename: blob.filename.sanitized, disposition: 'inline', type: blob.content_type) do |stream|
|
|
90
|
+
blob.download { |chunk| stream.write(chunk) }
|
|
91
|
+
end
|
|
92
|
+
rescue ActiveStorage::FileNotFoundError
|
|
93
|
+
expires_now
|
|
94
|
+
head :not_found
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def send_audio_byte_range_data(blob, range_header)
|
|
98
|
+
ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
|
|
99
|
+
range = ranges.first if ranges&.one?
|
|
100
|
+
return head :range_not_satisfiable unless range
|
|
101
|
+
return head :range_not_satisfiable if range.end - range.begin >= streaming_chunk_max_size
|
|
102
|
+
|
|
103
|
+
data = blob.download_chunk(range)
|
|
104
|
+
response.headers['Content-Range'] = "bytes #{range.begin}-#{range.end}/#{blob.byte_size}"
|
|
105
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
106
|
+
response.headers['Content-Length'] = data.bytesize.to_s
|
|
107
|
+
send_data data, filename: blob.filename.sanitized, disposition: 'inline',
|
|
108
|
+
status: :partial_content, type: blob.content_type
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rails 8 exposes this knob; Rails 7.1/7.2 do not. Match the newer default
|
|
112
|
+
# on older supported releases so a Range header cannot force an unbounded
|
|
113
|
+
# in-memory chunk.
|
|
114
|
+
def streaming_chunk_max_size
|
|
115
|
+
return ActiveStorage.streaming_chunk_max_size if ActiveStorage.respond_to?(:streaming_chunk_max_size)
|
|
116
|
+
|
|
117
|
+
100 * 1024 * 1024
|
|
59
118
|
end
|
|
60
119
|
end
|
|
61
120
|
end
|
|
@@ -90,7 +90,7 @@ module Livechat
|
|
|
90
90
|
visitor_token: current_visitor_id ? visitor_token : ensure_visitor_token,
|
|
91
91
|
visitor_label: visitor_label,
|
|
92
92
|
visitor_email: (current_visitor.try(:email).presence if current_visitor),
|
|
93
|
-
page_url: params[:page_url]
|
|
93
|
+
page_url: clean_page_url(params[:page_url]),
|
|
94
94
|
locale: params[:locale].to_s.first(10).presence
|
|
95
95
|
)
|
|
96
96
|
end
|
|
@@ -105,7 +105,8 @@ module Livechat
|
|
|
105
105
|
# "where are they stuck right now" is what an answering agent needs.
|
|
106
106
|
def refresh_context(conversation)
|
|
107
107
|
updates = {}
|
|
108
|
-
|
|
108
|
+
page_url = clean_page_url(params[:page_url])
|
|
109
|
+
updates[:page_url] = page_url if page_url
|
|
109
110
|
updates[:locale] = params[:locale].to_s.first(10) if params[:locale].present?
|
|
110
111
|
conversation.update_columns(updates) if updates.any?
|
|
111
112
|
end
|
|
@@ -20,6 +20,27 @@ module Livechat
|
|
|
20
20
|
serve(Widget.javascript, Widget.fingerprint)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Bootstraps the widget on a page this app does not render. Carries the
|
|
24
|
+
# config the layout helper would have rendered, so an external page needs
|
|
25
|
+
# one <script src> and nothing that has to keep up with this engine.
|
|
26
|
+
#
|
|
27
|
+
# Never cached: it reflects the current visitor and whatever copy the host
|
|
28
|
+
# resolves per request. The heavy widget.js it pulls in stays immutable.
|
|
29
|
+
def embed
|
|
30
|
+
body = if Livechat.enabled?(request)
|
|
31
|
+
I18n.with_locale(embed_locale) do
|
|
32
|
+
Widget.embed_javascript(locale: I18n.locale,
|
|
33
|
+
authenticated: current_visitor.present?,
|
|
34
|
+
settings: Livechat.settings_for(request))
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
''
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
41
|
+
render plain: body, content_type: 'text/javascript'
|
|
42
|
+
end
|
|
43
|
+
|
|
23
44
|
def dashboard
|
|
24
45
|
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint, 'text/javascript')
|
|
25
46
|
end
|
|
@@ -30,6 +51,14 @@ module Livechat
|
|
|
30
51
|
|
|
31
52
|
private
|
|
32
53
|
|
|
54
|
+
# An embedding site carries its own language; it asks for the matching
|
|
55
|
+
# copy with ?locale=. Anything the host does not actually have falls back
|
|
56
|
+
# rather than raising.
|
|
57
|
+
def embed_locale
|
|
58
|
+
requested = params[:locale].to_s
|
|
59
|
+
I18n.available_locales.map(&:to_s).include?(requested) ? requested : I18n.locale
|
|
60
|
+
end
|
|
61
|
+
|
|
33
62
|
def serve(source, fingerprint, content_type = 'text/javascript')
|
|
34
63
|
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
35
64
|
return unless stale?(etag: [Livechat::VERSION, fingerprint])
|
|
@@ -19,6 +19,20 @@ module Livechat
|
|
|
19
19
|
|
|
20
20
|
scope :recent_first, -> { order(last_activity_at: :desc, id: :desc) }
|
|
21
21
|
|
|
22
|
+
# Host-controlled retention. Only resolved threads whose last activity is
|
|
23
|
+
# older than the supplied cutoff are removed; open conversations are never
|
|
24
|
+
# deleted automatically. Destroying (rather than delete_all) lets Rails run
|
|
25
|
+
# the message and Active Storage attachment lifecycle. Returns the number
|
|
26
|
+
# of conversations destroyed so a scheduled job can report what it did.
|
|
27
|
+
def self.purge_resolved(older_than:)
|
|
28
|
+
destroyed = 0
|
|
29
|
+
where(status: 'resolved', last_activity_at: ...older_than).find_each do |conversation|
|
|
30
|
+
conversation.destroy!
|
|
31
|
+
destroyed += 1
|
|
32
|
+
end
|
|
33
|
+
destroyed
|
|
34
|
+
end
|
|
35
|
+
|
|
22
36
|
STATUSES.each do |status|
|
|
23
37
|
define_method(:"#{status}?") { self.status == status }
|
|
24
38
|
end
|
|
@@ -108,8 +108,8 @@ module Livechat
|
|
|
108
108
|
{ id: file.id,
|
|
109
109
|
name: file.filename.to_s,
|
|
110
110
|
url: "#{base}/attachments/#{file.id}",
|
|
111
|
-
image:
|
|
112
|
-
audio:
|
|
111
|
+
image: AttachmentPolicy.image?(content_type),
|
|
112
|
+
audio: AttachmentPolicy.audio?(content_type),
|
|
113
113
|
size: file.byte_size }
|
|
114
114
|
end
|
|
115
115
|
end
|
data/config/routes.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Livechat::Engine.routes.draw do
|
|
4
4
|
get 'widget.js', to: 'widgets#show', as: :widget
|
|
5
|
+
get 'embed.js', to: 'widgets#embed', as: :embed
|
|
5
6
|
get 'dashboard.js', to: 'widgets#dashboard', as: :dashboard_script
|
|
6
7
|
get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
|
|
7
8
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# The small set of uploads the engine renders in a browser instead of
|
|
5
|
+
# downloading. Active Storage owns the safe image/PDF allowlist; Livechat
|
|
6
|
+
# adds common audio formats because voice messages are a first-class feature.
|
|
7
|
+
module AttachmentPolicy
|
|
8
|
+
INLINE_AUDIO_TYPES = %w[
|
|
9
|
+
audio/mpeg audio/mp4 audio/ogg audio/wav audio/webm audio/x-wav
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def inline?(content_type)
|
|
15
|
+
audio?(content_type) || ActiveStorage.content_types_allowed_inline.include?(content_type)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def image?(content_type)
|
|
19
|
+
content_type.to_s.start_with?('image/') &&
|
|
20
|
+
ActiveStorage.content_types_allowed_inline.include?(content_type)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def audio?(content_type)
|
|
24
|
+
INLINE_AUDIO_TYPES.include?(content_type)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -61,6 +61,9 @@ module Livechat
|
|
|
61
61
|
|
|
62
62
|
# Widget copy. nil uses the localized defaults. reply_time_text is the
|
|
63
63
|
# honest line under the greeting — "We usually reply within a few hours."
|
|
64
|
+
# A String, or a callable receiving the request: one app serving both an
|
|
65
|
+
# in-app widget and an embedded one on a marketing site needs to greet
|
|
66
|
+
# those visitors differently.
|
|
64
67
|
attr_accessor :greeting, :reply_time_text, :launcher_label
|
|
65
68
|
|
|
66
69
|
# Optional customer-facing avatar shown in the widget header. Use a URL
|
|
@@ -70,13 +73,29 @@ module Livechat
|
|
|
70
73
|
|
|
71
74
|
# Brand color for the widget (launcher, header, visitor bubbles, send
|
|
72
75
|
# button) as a hex value, e.g. "#7c3aed". The widget picks black or white
|
|
73
|
-
# text automatically for contrast. nil keeps the built-in blue.
|
|
76
|
+
# text automatically for contrast. nil keeps the built-in blue. A callable
|
|
77
|
+
# receiving the request works too.
|
|
74
78
|
attr_accessor :accent_color
|
|
75
79
|
|
|
76
80
|
# The floating launcher bubble. Set false to open the widget only from
|
|
77
|
-
# your own elements carrying `data-livechat-open`.
|
|
81
|
+
# your own elements carrying `data-livechat-open`. Also accepts a callable
|
|
82
|
+
# receiving the request, for a host that wants the bubble on one surface
|
|
83
|
+
# and its own opener on another.
|
|
78
84
|
attr_accessor :show_launcher
|
|
79
85
|
|
|
86
|
+
# Origins allowed to embed the widget on pages this app does not render —
|
|
87
|
+
# a static marketing site that proxies `mount_path` through to this app,
|
|
88
|
+
# say. An Array of origins ("https://example.com"), or a callable
|
|
89
|
+
# receiving the request and returning one.
|
|
90
|
+
#
|
|
91
|
+
# Such a page cannot carry a Rails CSRF token, so a matching request is
|
|
92
|
+
# exempted from forgery protection. That exemption rests on the Origin
|
|
93
|
+
# header, which the browser sets and script cannot forge, and applies only
|
|
94
|
+
# to the widget's own visitor endpoints — never to the inbox. A request
|
|
95
|
+
# arriving without an Origin at all (curl, a server-side client) is never
|
|
96
|
+
# embedded and keeps the full check.
|
|
97
|
+
attr_accessor :allowed_embed_origins
|
|
98
|
+
|
|
80
99
|
# Email addresses to notify when a visitor writes and nobody has read it —
|
|
81
100
|
# an array, or a callable returning one. nil disables the built-in email.
|
|
82
101
|
attr_accessor :agent_emails
|
|
@@ -140,6 +159,7 @@ module Livechat
|
|
|
140
159
|
@avatar_url = nil
|
|
141
160
|
@accent_color = nil
|
|
142
161
|
@show_launcher = true
|
|
162
|
+
@allowed_embed_origins = []
|
|
143
163
|
@agent_emails = nil
|
|
144
164
|
@mailer_from = nil
|
|
145
165
|
@on_visitor_message = ->(_message) {}
|
data/lib/livechat/version.rb
CHANGED
data/lib/livechat/widget.rb
CHANGED
|
@@ -58,29 +58,54 @@ module Livechat
|
|
|
58
58
|
# nonce gets refused; a same-origin src is covered by `'self'` on every
|
|
59
59
|
# visit. `nonce:` is still stamped for hosts whose script-src has no
|
|
60
60
|
# 'self'; pass nil when the app has no nonce.
|
|
61
|
-
def snippet(locale:, authenticated:,
|
|
62
|
-
json = config_json(locale:, authenticated:,
|
|
61
|
+
def snippet(locale:, authenticated:, settings: nil, nonce: nil)
|
|
62
|
+
json = config_json(locale:, authenticated:, settings:)
|
|
63
63
|
nonce_attr = nonce ? %( nonce="#{nonce}") : ''
|
|
64
|
-
src = "#{Livechat.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
|
|
65
64
|
|
|
66
65
|
%(<script type="application/json" data-livechat-config>#{json}</script>) +
|
|
67
|
-
%(<script src="#{
|
|
66
|
+
%(<script src="#{script_src}" defer#{nonce_attr} data-livechat-widget></script>)
|
|
68
67
|
end
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
# The same two tags, assembled by the browser instead of by a Rails
|
|
70
|
+
# template. A page this app does not render — a static marketing site
|
|
71
|
+
# proxying the mount path through to Rails — gets the widget from one
|
|
72
|
+
# plain <script src>, and never reproduces the config block itself.
|
|
73
|
+
def embed_javascript(locale:, authenticated:, settings: nil)
|
|
74
|
+
<<~JS
|
|
75
|
+
/* livechat embed */
|
|
76
|
+
(function () {
|
|
77
|
+
if (window.__livechatEmbedded) return;
|
|
78
|
+
window.__livechatEmbedded = true;
|
|
79
|
+
|
|
80
|
+
var config = document.createElement("script");
|
|
81
|
+
config.type = "application/json";
|
|
82
|
+
config.setAttribute("data-livechat-config", "");
|
|
83
|
+
config.textContent = JSON.stringify(#{config_json(locale:, authenticated:, settings:)});
|
|
84
|
+
document.head.appendChild(config);
|
|
85
|
+
|
|
86
|
+
var widget = document.createElement("script");
|
|
87
|
+
widget.src = #{script_src.to_json};
|
|
88
|
+
widget.setAttribute("data-livechat-widget", "");
|
|
89
|
+
document.head.appendChild(widget);
|
|
90
|
+
})();
|
|
91
|
+
JS
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def config_json(locale:, authenticated:, settings: nil)
|
|
71
95
|
config = Livechat.config
|
|
96
|
+
settings ||= Livechat.settings_for(nil)
|
|
72
97
|
payload = {
|
|
73
98
|
endpoint: config.widget_endpoint,
|
|
74
99
|
locale: locale.to_s,
|
|
75
100
|
rtl: rtl?(locale),
|
|
76
101
|
authenticated: authenticated ? true : false,
|
|
77
|
-
launcher:
|
|
102
|
+
launcher: settings[:show_launcher] ? true : false,
|
|
78
103
|
appName: Livechat.app_name,
|
|
79
|
-
avatarUrl: avatar_url,
|
|
80
|
-
accentColor:
|
|
104
|
+
avatarUrl: settings[:avatar_url],
|
|
105
|
+
accentColor: settings[:accent_color],
|
|
81
106
|
attachments: Livechat.attachments_enabled?,
|
|
82
107
|
maxAttachments: config.max_attachments,
|
|
83
|
-
labels: labels
|
|
108
|
+
labels: labels(settings)
|
|
84
109
|
}
|
|
85
110
|
# Escape "</" so a value can't close the <script> block early.
|
|
86
111
|
payload.to_json.gsub('</', '<\/')
|
|
@@ -88,16 +113,19 @@ module Livechat
|
|
|
88
113
|
|
|
89
114
|
private
|
|
90
115
|
|
|
116
|
+
def script_src
|
|
117
|
+
"#{Livechat.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
|
|
118
|
+
end
|
|
119
|
+
|
|
91
120
|
# Every user-facing string in the widget, resolved through Rails I18n so
|
|
92
121
|
# the chat follows the app's current locale. Each lookup carries an
|
|
93
122
|
# English default, so the widget stays fully worded even when a key is
|
|
94
123
|
# missing for the active locale.
|
|
95
|
-
def labels
|
|
96
|
-
config = Livechat.config
|
|
124
|
+
def labels(settings)
|
|
97
125
|
{
|
|
98
|
-
launcher:
|
|
99
|
-
greeting:
|
|
100
|
-
replyTime:
|
|
126
|
+
launcher: settings[:launcher_label].presence || t(:launcher, 'Chat with us'),
|
|
127
|
+
greeting: settings[:greeting].presence || t(:greeting, 'Hi! How can we help?'),
|
|
128
|
+
replyTime: settings[:reply_time_text].presence ||
|
|
101
129
|
t(:reply_time, 'We usually reply within a few hours.'),
|
|
102
130
|
placeholder: t(:placeholder, 'Write a message…'),
|
|
103
131
|
send: t(:send, 'Send'),
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Livechat
|
|
4
|
-
# Included into the host's ActionView.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# from `window.Livechat.open()`.
|
|
4
|
+
# Included into the host's ActionView. Kept under lib and required before the
|
|
5
|
+
# engine registers its Action View load hook: a host initializer may load
|
|
6
|
+
# Action View before Rails sets up application autoloaders, when a helper
|
|
7
|
+
# living only under app/helpers is not yet a resolvable constant.
|
|
9
8
|
module WidgetHelper
|
|
10
9
|
def livechat_tag
|
|
11
10
|
return unless Livechat.enabled?(request)
|
|
@@ -13,7 +12,7 @@ module Livechat
|
|
|
13
12
|
Widget.snippet(
|
|
14
13
|
locale: I18n.locale,
|
|
15
14
|
authenticated: livechat_visitor.present?,
|
|
16
|
-
|
|
15
|
+
settings: Livechat.settings_for(request),
|
|
17
16
|
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
18
17
|
).html_safe
|
|
19
18
|
end
|
|
@@ -29,17 +28,11 @@ module Livechat
|
|
|
29
28
|
data = (options.delete(:data) { {} } || {}).merge(livechat_open: '')
|
|
30
29
|
data[:livechat_message] = message if message.present?
|
|
31
30
|
|
|
32
|
-
tag.button(label, type: 'button', data
|
|
31
|
+
tag.button(label, type: 'button', data:, **options)
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
private
|
|
36
35
|
|
|
37
|
-
def livechat_avatar_url
|
|
38
|
-
value = Livechat.config.avatar_url
|
|
39
|
-
value = value.call(request) if value.respond_to?(:call)
|
|
40
|
-
value.to_s.presence
|
|
41
|
-
end
|
|
42
|
-
|
|
43
36
|
def livechat_visitor
|
|
44
37
|
return @livechat_visitor if defined?(@livechat_visitor)
|
|
45
38
|
|
data/lib/livechat.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'livechat/version'
|
|
4
4
|
require 'livechat/configuration'
|
|
5
5
|
require 'livechat/widget'
|
|
6
|
+
require 'livechat/widget_helper'
|
|
7
|
+
require 'livechat/attachment_policy'
|
|
6
8
|
require 'livechat/notifications'
|
|
7
9
|
require 'livechat/seeds'
|
|
8
10
|
require 'livechat/engine'
|
|
@@ -28,6 +30,49 @@ module Livechat
|
|
|
28
30
|
!!config.enabled.call(request)
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
# The engine's public surface: the widget script, the embed bootstrap, the
|
|
34
|
+
# visitor API and the attachment route. All of them answer with JavaScript,
|
|
35
|
+
# JSON or a file, and none of them need a Rails session.
|
|
36
|
+
#
|
|
37
|
+
# Exposed because a host usually has session, CSP or caching rules written
|
|
38
|
+
# for its own HTML pages, and applying those here costs every visitor a
|
|
39
|
+
# cookie for a request that never had a use for one.
|
|
40
|
+
def public_paths
|
|
41
|
+
mount = config.mount_path.chomp('/')
|
|
42
|
+
["#{mount}/widget.js", "#{mount}/embed.js", "#{mount}/widget/", "#{mount}/attachments/"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resolve a setting that may be a plain value or a per-request callable.
|
|
46
|
+
def resolve(value, request)
|
|
47
|
+
value.respond_to?(:call) ? value.call(request) : value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The per-request widget settings a host may vary by surface. Resolved by
|
|
51
|
+
# the helper and by the embed script, so both routes see the same values.
|
|
52
|
+
def settings_for(request)
|
|
53
|
+
{
|
|
54
|
+
greeting: resolve(config.greeting, request),
|
|
55
|
+
reply_time_text: resolve(config.reply_time_text, request),
|
|
56
|
+
launcher_label: resolve(config.launcher_label, request),
|
|
57
|
+
show_launcher: resolve(config.show_launcher, request),
|
|
58
|
+
accent_color: resolve(config.accent_color, request),
|
|
59
|
+
avatar_url: resolve(config.avatar_url, request).to_s.presence
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Is this request coming from a page on an allowlisted external origin?
|
|
64
|
+
#
|
|
65
|
+
# Only ever true for a request the browser stamped with a matching Origin.
|
|
66
|
+
# A missing Origin is not a match: a non-browser client must not inherit a
|
|
67
|
+
# browser-guaranteed exemption.
|
|
68
|
+
def embedded?(request)
|
|
69
|
+
origin = request.origin.presence
|
|
70
|
+
return false unless origin
|
|
71
|
+
|
|
72
|
+
allowed = resolve(config.allowed_embed_origins, request)
|
|
73
|
+
Array(allowed).any? { |candidate| candidate.to_s.chomp('/') == origin.chomp('/') }
|
|
74
|
+
end
|
|
75
|
+
|
|
31
76
|
# Can this request work the inbox? Checked by every inbox action.
|
|
32
77
|
# The class Livechat::DashboardController inherits from. Resolved on every
|
|
33
78
|
# call rather than memoized, so a host that reassigns base_controller_class
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: livechat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -16,6 +16,9 @@ dependencies:
|
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -23,6 +26,9 @@ dependencies:
|
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
26
32
|
description: |
|
|
27
33
|
A mountable Rails engine that adds support chat to your app — the thing
|
|
28
34
|
you'd otherwise pay Crisp or Intercom for, or deploy Chatwoot to get. A
|
|
@@ -43,6 +49,7 @@ files:
|
|
|
43
49
|
- MIT-LICENSE
|
|
44
50
|
- README.md
|
|
45
51
|
- Rakefile
|
|
52
|
+
- SECURITY.md
|
|
46
53
|
- app/controllers/concerns/livechat/request_context.rb
|
|
47
54
|
- app/controllers/livechat/application_controller.rb
|
|
48
55
|
- app/controllers/livechat/attachments_controller.rb
|
|
@@ -52,7 +59,6 @@ files:
|
|
|
52
59
|
- app/controllers/livechat/visitor_controller.rb
|
|
53
60
|
- app/controllers/livechat/widgets_controller.rb
|
|
54
61
|
- app/helpers/livechat/inbox_helper.rb
|
|
55
|
-
- app/helpers/livechat/widget_helper.rb
|
|
56
62
|
- app/mailers/livechat/mailer.rb
|
|
57
63
|
- app/models/livechat/application_record.rb
|
|
58
64
|
- app/models/livechat/conversation.rb
|
|
@@ -96,6 +102,7 @@ files:
|
|
|
96
102
|
- lib/generators/livechat/install/templates/initializer.rb
|
|
97
103
|
- lib/generators/livechat/migration_helpers.rb
|
|
98
104
|
- lib/livechat.rb
|
|
105
|
+
- lib/livechat/attachment_policy.rb
|
|
99
106
|
- lib/livechat/channels.rb
|
|
100
107
|
- lib/livechat/configuration.rb
|
|
101
108
|
- lib/livechat/dashboard.css
|
|
@@ -106,13 +113,14 @@ files:
|
|
|
106
113
|
- lib/livechat/version.rb
|
|
107
114
|
- lib/livechat/widget.js
|
|
108
115
|
- lib/livechat/widget.rb
|
|
116
|
+
- lib/livechat/widget_helper.rb
|
|
109
117
|
- lib/tasks/livechat_tasks.rake
|
|
110
118
|
homepage: https://github.com/yshmarov/livechat
|
|
111
119
|
licenses:
|
|
112
120
|
- MIT
|
|
113
121
|
metadata:
|
|
114
122
|
homepage_uri: https://github.com/yshmarov/livechat
|
|
115
|
-
source_code_uri: https://github.com/yshmarov/livechat
|
|
123
|
+
source_code_uri: https://github.com/yshmarov/livechat/tree/main
|
|
116
124
|
changelog_uri: https://github.com/yshmarov/livechat/blob/main/CHANGELOG.md
|
|
117
125
|
bug_tracker_uri: https://github.com/yshmarov/livechat/issues
|
|
118
126
|
rubygems_mfa_required: 'true'
|
|
@@ -130,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
130
138
|
- !ruby/object:Gem::Version
|
|
131
139
|
version: '0'
|
|
132
140
|
requirements: []
|
|
133
|
-
rubygems_version: 4.0.
|
|
141
|
+
rubygems_version: 4.0.20
|
|
134
142
|
specification_version: 4
|
|
135
143
|
summary: 'Open-source live chat for Rails: a drop-in support widget plus a team inbox,
|
|
136
144
|
in your own database.'
|