tuix_rails 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/assets/bootstrap.js +143 -0
- data/lib/generators/tuix/install_generator.rb +26 -0
- data/lib/tuix_rails/assets.rb +29 -0
- data/lib/tuix_rails/helper.rb +18 -0
- data/lib/tuix_rails/railtie.rb +13 -0
- data/lib/tuix_rails/version.rb +3 -0
- data/lib/tuix_rails.rb +81 -0
- metadata +93 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dd2c3676824936348b36faf27edba0191067f31a2d2ecce7c5d5e4f33b05178d
|
|
4
|
+
data.tar.gz: 1be22f93ab08113a150736b9a2d7726076a36481c756c8edbbf0af2e2e87e190
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6a58fd4728e42ef4be71e8bec5e623245319d819e716e81778d2eca5a8ae4c17da31cfcc28c8c6f484285f46093008d6d71f82e8cf077630618544905ebcb576
|
|
7
|
+
data.tar.gz: ca64d02cb52fce897c1f9610976d4b92ec0f1c4f9408aaec4b18b1978d8ecb0f82d1a4061e44a95ca44c22debd1dd4328766418aceb862b9ed0a3c40ecf53b78
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maciek Wciślik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# tuix_rails
|
|
2
|
+
|
|
3
|
+
Rails integration for the existing Tuix widget: server-signed identities, hosted
|
|
4
|
+
widget delivery, CSP nonces and Turbo navigation. Requires Ruby 3.2+ and Rails
|
|
5
|
+
7.2–8.x (Rails itself may require a newer Ruby). No database, npm installation,
|
|
6
|
+
asset precompile or separate Tuix login is needed by the host application.
|
|
7
|
+
|
|
8
|
+
MIT licensed. Until the gem is on RubyGems, install it from a checkout of this
|
|
9
|
+
repository or from a built gem, as below.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Add the package to the host application's Gemfile, using a checkout of this
|
|
14
|
+
repository or an unpacked gem artifact:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "tuix_rails", path: "/absolute/path/to/tuix/packages/rails"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For the built artifact, run `gem unpack output/releases/tuix_rails-0.1.0.gem`
|
|
21
|
+
and point `path:` at the resulting `tuix_rails-0.1.0` directory.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bundle install
|
|
25
|
+
bin/rails generate tuix:install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The generator creates `config/initializers/tuix.rb`. It prints the layout steps
|
|
29
|
+
instead of guessing your authentication library or rewriting custom layouts.
|
|
30
|
+
Re-running it uses Rails' normal file conflict handling; `--skip` preserves a
|
|
31
|
+
custom initializer.
|
|
32
|
+
|
|
33
|
+
Set `TUIX_PROJECT_KEY`, `TUIX_SECRET_KEY`, `TUIX_API_URL` (default
|
|
34
|
+
`https://tuix.app`) and `TUIX_ENVIRONMENT` (default `uat`) on the host server.
|
|
35
|
+
Allow the host's exact origin in Tuix project settings and keep signed identity
|
|
36
|
+
enabled. HTTP API origins are accepted only for localhost/127.0.0.1 development.
|
|
37
|
+
|
|
38
|
+
Add the asset helper inside your layout's `<head>`:
|
|
39
|
+
|
|
40
|
+
```erb
|
|
41
|
+
<%= tuix_assets %>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Add one identity helper inside `<body>`, outside any fragment shared across users:
|
|
45
|
+
|
|
46
|
+
```erb
|
|
47
|
+
<%= tuix_widget(user: current_user && {
|
|
48
|
+
id: current_user.id,
|
|
49
|
+
name: current_user.name,
|
|
50
|
+
email: current_user.email
|
|
51
|
+
}) %>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Adapt `current_user` to your authentication system. Pass `nil` for signed-out
|
|
55
|
+
users or users who should not have feedback access. Supply only trusted server
|
|
56
|
+
identity data, never request parameters. Include email only when your application
|
|
57
|
+
has verified it. Tuix uses the signed email for dashboard access and notifications;
|
|
58
|
+
omit it if you cannot vouch for the address. IDs must be stable strings or integers.
|
|
59
|
+
|
|
60
|
+
The gem signs the exact ID with HMAC-SHA256, or the ID, a newline and the trimmed,
|
|
61
|
+
lowercased email when present (Tuix identity v2). The signing secret never goes
|
|
62
|
+
into HTML. `TuixRails.identity(user)` is also available to sign identities for a
|
|
63
|
+
separate JavaScript frontend. `config.release` optionally identifies a deployed
|
|
64
|
+
build. Missing project/secret or an anonymous user leaves the widget inactive;
|
|
65
|
+
invalid supplied configuration or identity raises an actionable argument error.
|
|
66
|
+
|
|
67
|
+
## Navigation and delivery
|
|
68
|
+
|
|
69
|
+
The gem serves its small bootstrap at a content-addressed `/tuix-rails/` URL.
|
|
70
|
+
Authenticated pages fetch the configured Tuix origin's public
|
|
71
|
+
`/widget/channels/stable.json` without cookies, validate the manifest, then load
|
|
72
|
+
the immutable widget with SHA-384 integrity. One runtime is selected per document.
|
|
73
|
+
Widget updates arrive on a subsequent full page load without upgrading the gem.
|
|
74
|
+
Bootstrap changes require upgrading the gem and deploying the host.
|
|
75
|
+
|
|
76
|
+
Turbo renders, frames and streams can replace the identity element. The bridge
|
|
77
|
+
destroys the old widget before identifying the new user. Removing the element or
|
|
78
|
+
rendering `user: nil` clears it. Pending loads cannot re-identify after logout.
|
|
79
|
+
The bridge has bounded manifest/script timeouts and retries after navigation.
|
|
80
|
+
Anonymous pages do not request the manifest or widget runtime.
|
|
81
|
+
|
|
82
|
+
Before Turbo caches a page, the bridge removes its widget and signed identity.
|
|
83
|
+
Cached previews stay inactive; a fresh server render restores feedback. A history
|
|
84
|
+
restoration served entirely from Turbo's cache therefore has no feedback identity.
|
|
85
|
+
For authenticated pages that need feedback on every back/forward visit, add this
|
|
86
|
+
to the layout's `<head>` so Turbo fetches fresh server identity:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<meta name="turbo-cache-control" content="no-cache">
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Clear your application's Turbo cache on logout as part of its normal session
|
|
93
|
+
handling. Do not mark the identity element `data-turbo-permanent` or put it in a
|
|
94
|
+
shared full-page cache. Browser back/forward cache follows the host's existing
|
|
95
|
+
session policy; the bridge destroys the widget on `pagehide` and re-reads the
|
|
96
|
+
current page on `pageshow`. See the [Turbo lifecycle documentation](https://turbo.hotwired.dev/handbook/building).
|
|
97
|
+
|
|
98
|
+
## Content security policy
|
|
99
|
+
|
|
100
|
+
Helpers use Rails' `content_security_policy_nonce` by default; an explicit
|
|
101
|
+
`nonce:` can be passed to both. The bootstrap passes it to the widget loader and
|
|
102
|
+
styles. Keep the nonce consistent with the document's policy across Turbo visits.
|
|
103
|
+
Keep Rails' `<%= csp_meta_tag %>` in the layout head so Turbo can also use that
|
|
104
|
+
nonce for its own scripts and progress styles. The gem does not replace the host's policy.
|
|
105
|
+
|
|
106
|
+
Allow the local bootstrap and the Tuix origin in applicable script directives,
|
|
107
|
+
Tuix fonts, Tuix HTTP/WebSocket connections (including manifest fetches), and
|
|
108
|
+
the storage origin shown in Project settings for uploads and images. Image
|
|
109
|
+
previews need `blob:` and `data:`. Capture requires `style-src-attr 'unsafe-inline'`
|
|
110
|
+
and the empty stylesheet hash
|
|
111
|
+
`'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='` in the applicable style
|
|
112
|
+
directive. Account for `script-src-elem`/`style-src-elem` when present. Test capture
|
|
113
|
+
against the real host policy; Tuix → Project settings → Install tuix → Content
|
|
114
|
+
Security Policy lists the exact sources for your project.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
cd packages/rails
|
|
120
|
+
bundle install
|
|
121
|
+
bundle exec ruby -Ilib test/integration_test.rb
|
|
122
|
+
bundle exec ruby ../../scripts/package-rails.rb
|
|
123
|
+
# From the repository root, after npm ci:
|
|
124
|
+
npm test -w @tuix/rails
|
|
125
|
+
npm run build -w @tuix/widget
|
|
126
|
+
npm run test:browser -w @tuix/rails
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`RAILS_VERSION='~> 7.2.0' bundle update` selects a different supported Rails line.
|
|
130
|
+
CI covers Rails 7.2/Ruby 3.2, Rails 8.0/Ruby 3.3 and Rails 8.1/Ruby 3.4.
|
|
131
|
+
Integration tests boot a real Rails application, render helpers, fetch the asset
|
|
132
|
+
through middleware, check identity signing/escaping, and exercise the generator.
|
|
133
|
+
The package command builds `output/releases/tuix_rails-0.1.0.gem`, verifies its
|
|
134
|
+
contents and reruns integration tests against a temporary gem installation before copying
|
|
135
|
+
the artifact into place. It never publishes. CI uploads the same local artifact, and the
|
|
136
|
+
release workflow publishes that artifact.
|
|
137
|
+
|
|
138
|
+
Browser regression tests need Playwright Chromium (`npx playwright install chromium`).
|
|
139
|
+
They boot Rails on loopback and exercise the actual Turbo library and built Tuix
|
|
140
|
+
widget: Drive, frames, streams, logout, back navigation, CSP nonces and SRI rejection.
|
|
141
|
+
The remote Tuix API is a deterministic local fixture that verifies Ruby signatures
|
|
142
|
+
with an independent Node HMAC implementation. jsdom tests cover pending-load races
|
|
143
|
+
and recovery. No store/account, Docker or production keys are needed.
|
|
144
|
+
|
|
145
|
+
Locally verified on 11 October 2026 with Ruby 3.2.4: all 9 integration tests passed
|
|
146
|
+
on Rails 7.2.4, 8.0.5.1 and 8.1.4; all 8 bootstrap tests and the Chromium/Turbo
|
|
147
|
+
8.0.23 browser smoke passed. The CI matrix also exercises newer Ruby versions;
|
|
148
|
+
its remote run is separate from these local results.
|
|
149
|
+
|
|
150
|
+
Before rolling out to your users, try report creation, screenshots and notifications
|
|
151
|
+
against a real Tuix project using the host's authentication, origin and CSP.
|
data/assets/bootstrap.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/* A page selects one runtime. Signed identity follows Rails renders and Turbo navigation. */
|
|
2
|
+
(() => {
|
|
3
|
+
const root = document.documentElement;
|
|
4
|
+
if (root.hasAttribute('data-tuix-rails-bound')) return;
|
|
5
|
+
root.setAttribute('data-tuix-rails-bound', '');
|
|
6
|
+
const sri = /^sha384-[A-Za-z0-9+/]{64}$/;
|
|
7
|
+
const immutable = /^\/widget\/(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\/[a-f0-9]{64}\/tuix\.js$/;
|
|
8
|
+
let previous, failed, selected, loading, controller;
|
|
9
|
+
let generation = 0;
|
|
10
|
+
let suspended = false;
|
|
11
|
+
|
|
12
|
+
function clear() {
|
|
13
|
+
generation++;
|
|
14
|
+
controller?.abort();
|
|
15
|
+
controller = undefined;
|
|
16
|
+
if (previous !== undefined && typeof window.tuix === 'function') window.tuix('destroy');
|
|
17
|
+
previous = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function apiOrigin(value) {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
23
|
+
!['', '/'].includes(url.pathname) ||
|
|
24
|
+
!(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))) {
|
|
25
|
+
throw Error('Invalid Tuix API origin');
|
|
26
|
+
}
|
|
27
|
+
return url.origin;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runtime(manifest, origin) {
|
|
31
|
+
const match = typeof manifest?.loader?.url === 'string' && manifest.loader.url.match(immutable);
|
|
32
|
+
if (manifest?.protocol !== 1 || !match || manifest.version !== match[1] ||
|
|
33
|
+
!sri.test(manifest.loader.integrity) ||
|
|
34
|
+
(manifest.directory !== undefined && manifest.directory !== manifest.loader.url.slice(8, -8))) {
|
|
35
|
+
throw Error('Invalid Tuix runtime manifest');
|
|
36
|
+
}
|
|
37
|
+
return { url: origin + manifest.loader.url, integrity: manifest.loader.integrity };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function select(payload, token) {
|
|
41
|
+
const origin = apiOrigin(payload.options.api);
|
|
42
|
+
if (selected) {
|
|
43
|
+
if (selected.origin !== origin || selected.delivery !== payload.delivery) throw Error('Runtime already selected');
|
|
44
|
+
return selected;
|
|
45
|
+
}
|
|
46
|
+
let entry;
|
|
47
|
+
if (payload.delivery === 'hosted') {
|
|
48
|
+
const abort = new AbortController();
|
|
49
|
+
controller = abort;
|
|
50
|
+
const timeout = setTimeout(() => abort.abort(), 8000);
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(origin + '/widget/channels/stable.json', {
|
|
53
|
+
mode: 'cors', credentials: 'omit', redirect: 'error', signal: abort.signal
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) throw Error('Runtime unavailable');
|
|
56
|
+
entry = runtime(await response.json(), origin);
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
if (controller === abort) controller = undefined;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
throw Error('Invalid delivery mode');
|
|
63
|
+
}
|
|
64
|
+
if (token !== generation) throw Error('Identity changed');
|
|
65
|
+
selected = { ...entry, origin, delivery: payload.delivery };
|
|
66
|
+
return selected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function load(entry, nonce) {
|
|
70
|
+
if (loading) return loading;
|
|
71
|
+
loading = new Promise((resolve, reject) => {
|
|
72
|
+
const script = document.createElement('script');
|
|
73
|
+
script.src = entry.url;
|
|
74
|
+
script.integrity = entry.integrity;
|
|
75
|
+
script.crossOrigin = 'anonymous';
|
|
76
|
+
if (nonce) script.nonce = nonce;
|
|
77
|
+
const timeout = setTimeout(() => finish(false), 15000);
|
|
78
|
+
function finish(ok) {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
script.onload = script.onerror = null;
|
|
81
|
+
if (ok) resolve();
|
|
82
|
+
else { script.remove(); loading = undefined; reject(Error('Runtime failed')); }
|
|
83
|
+
}
|
|
84
|
+
script.onload = () => finish(true);
|
|
85
|
+
script.onerror = () => finish(false);
|
|
86
|
+
document.head.append(script);
|
|
87
|
+
});
|
|
88
|
+
return loading;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function synchronize() {
|
|
92
|
+
if (suspended || root.hasAttribute('data-turbo-preview')) return;
|
|
93
|
+
const encoded = document.getElementById('tuix-rails')?.getAttribute('data-tuix-rails');
|
|
94
|
+
if (!encoded) { if (previous !== undefined) clear(); return; }
|
|
95
|
+
if (encoded === previous || encoded === failed) return;
|
|
96
|
+
clear();
|
|
97
|
+
let payload;
|
|
98
|
+
try {
|
|
99
|
+
payload = JSON.parse(encoded);
|
|
100
|
+
if (!payload?.user?.id || !payload.user.name || !/^[a-f0-9]{64}$/.test(payload.user.hash) ||
|
|
101
|
+
!payload.options?.project) return;
|
|
102
|
+
} catch { return; }
|
|
103
|
+
previous = encoded;
|
|
104
|
+
const token = generation;
|
|
105
|
+
try {
|
|
106
|
+
const entry = await select(payload, token);
|
|
107
|
+
if (token !== generation) return;
|
|
108
|
+
await load(entry, payload.options.nonce);
|
|
109
|
+
if (token !== generation || suspended) return;
|
|
110
|
+
if (typeof window.tuix !== 'function') throw Error('Runtime missing');
|
|
111
|
+
window.tuix('destroy');
|
|
112
|
+
window.tuix('init', payload.options);
|
|
113
|
+
window.tuix('identify', payload.user);
|
|
114
|
+
} catch {
|
|
115
|
+
if (token !== generation) return;
|
|
116
|
+
failed = encoded;
|
|
117
|
+
clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pending = false;
|
|
122
|
+
function schedule() {
|
|
123
|
+
if (pending) return;
|
|
124
|
+
pending = true;
|
|
125
|
+
queueMicrotask(() => { pending = false; synchronize(); });
|
|
126
|
+
}
|
|
127
|
+
new MutationObserver(schedule).observe(root, {
|
|
128
|
+
childList: true, subtree: true, attributes: true, attributeFilter: ['data-tuix-rails']
|
|
129
|
+
});
|
|
130
|
+
function navigate() { failed = undefined; schedule(); }
|
|
131
|
+
// Stop before Turbo clones the page. Signed identity must not survive in its cache.
|
|
132
|
+
document.addEventListener('turbo:before-cache', () => {
|
|
133
|
+
suspended = true;
|
|
134
|
+
clear();
|
|
135
|
+
document.getElementById('tuix-rails')?.removeAttribute('data-tuix-rails');
|
|
136
|
+
});
|
|
137
|
+
document.addEventListener('turbo:before-render', () => { suspended = true; clear(); });
|
|
138
|
+
document.addEventListener('turbo:load', () => { suspended = false; navigate(); });
|
|
139
|
+
document.addEventListener('turbo:render', () => { suspended = false; navigate(); });
|
|
140
|
+
window.addEventListener('pagehide', () => { suspended = true; clear(); });
|
|
141
|
+
window.addEventListener('pageshow', () => { suspended = false; navigate(); });
|
|
142
|
+
synchronize();
|
|
143
|
+
})();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Tuix
|
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
|
5
|
+
desc "Configure Tuix and print the layout integration steps"
|
|
6
|
+
|
|
7
|
+
def create_configuration
|
|
8
|
+
initializer "tuix.rb", <<~RUBY
|
|
9
|
+
TuixRails.configure do |config|
|
|
10
|
+
config.project = ENV["TUIX_PROJECT_KEY"]
|
|
11
|
+
config.secret = ENV["TUIX_SECRET_KEY"]
|
|
12
|
+
config.api = ENV.fetch("TUIX_API_URL", "https://tuix.app")
|
|
13
|
+
config.environment = ENV.fetch("TUIX_ENVIRONMENT", "uat")
|
|
14
|
+
# config.release = ENV["APP_REVISION"]
|
|
15
|
+
end
|
|
16
|
+
RUBY
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def installation_steps
|
|
20
|
+
say "Add <%= tuix_assets %> to your layout's <head>."
|
|
21
|
+
say "Add <%= tuix_widget(user: your_authenticated_identity_or_nil) %> inside <body>."
|
|
22
|
+
say "Use a hash with :id, :name and optional verified :email. Never use request parameters."
|
|
23
|
+
say "See the gem README for Turbo cache, CSP and allowed-origin configuration."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module TuixRails
|
|
5
|
+
module Assets
|
|
6
|
+
BODY = File.binread(File.expand_path("../../assets/bootstrap.js", __dir__)).freeze
|
|
7
|
+
PATH = "/tuix-rails/#{Digest::SHA256.hexdigest(BODY)}/bootstrap.js".freeze
|
|
8
|
+
INTEGRITY = "sha384-#{Base64.strict_encode64(Digest::SHA384.digest(BODY))}".freeze
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Serve exactly one public asset; no filesystem path comes from the request.
|
|
12
|
+
class Middleware
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
return @app.call(env) unless env["PATH_INFO"] == Assets::PATH && %w[GET HEAD].include?(env["REQUEST_METHOD"])
|
|
19
|
+
headers = {
|
|
20
|
+
"content-type" => "text/javascript; charset=utf-8",
|
|
21
|
+
"content-length" => Assets::BODY.bytesize.to_s,
|
|
22
|
+
"cache-control" => "public, max-age=31536000, immutable",
|
|
23
|
+
"x-content-type-options" => "nosniff",
|
|
24
|
+
"cross-origin-resource-policy" => "same-origin"
|
|
25
|
+
}
|
|
26
|
+
[200, headers, env["REQUEST_METHOD"] == "HEAD" ? [] : [Assets::BODY]]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require_relative "assets"
|
|
2
|
+
|
|
3
|
+
module TuixRails
|
|
4
|
+
module Helper
|
|
5
|
+
def tuix_assets(nonce: content_security_policy_nonce)
|
|
6
|
+
path = "#{request.script_name}#{Assets::PATH}"
|
|
7
|
+
content_tag(:script, "", src: path, defer: true, integrity: Assets::INTEGRITY,
|
|
8
|
+
crossorigin: "anonymous", nonce: nonce, data: { turbo_track: "reload" })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def tuix_widget(user:, nonce: content_security_policy_nonce)
|
|
12
|
+
payload = TuixRails.payload(user, nonce: nonce)
|
|
13
|
+
# Action View escapes JSON as attribute text, including quotes and HTML.
|
|
14
|
+
content_tag(:div, "", id: "tuix-rails", hidden: true,
|
|
15
|
+
data: { tuix_rails: payload && JSON.generate(payload), turbo_temporary: true })
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
module TuixRails
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "tuix_rails.assets" do |app|
|
|
6
|
+
app.middleware.use TuixRails::Middleware
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "tuix_rails.helpers" do
|
|
10
|
+
ActiveSupport.on_load(:action_view) { include TuixRails::Helper }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/tuix_rails.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "tuix_rails/version"
|
|
5
|
+
|
|
6
|
+
module TuixRails
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :project, :secret, :api, :environment, :release
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@project = ENV["TUIX_PROJECT_KEY"]
|
|
12
|
+
@secret = ENV["TUIX_SECRET_KEY"]
|
|
13
|
+
@api = ENV.fetch("TUIX_API_URL", "https://tuix.app")
|
|
14
|
+
@environment = ENV.fetch("TUIX_ENVIRONMENT", "uat")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.configure
|
|
23
|
+
yield configuration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_text!(value, field, limit)
|
|
27
|
+
unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, limit) && !value.strip.empty?
|
|
28
|
+
raise ArgumentError, "invalid Tuix #{field}"
|
|
29
|
+
end
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Only pass trusted, authenticated host identities, with verified emails.
|
|
34
|
+
def self.identity(user, secret: configuration.secret)
|
|
35
|
+
return nil if user.nil?
|
|
36
|
+
raise ArgumentError, "Tuix user must be a hash with :id and :name" unless user.is_a?(Hash)
|
|
37
|
+
id = user[:id].is_a?(Integer) ? user[:id].to_s : user[:id]
|
|
38
|
+
validate_text!(id, :id, 200)
|
|
39
|
+
validate_text!(user[:name], :name, 200)
|
|
40
|
+
unless secret.is_a?(String) && secret.bytesize >= 32
|
|
41
|
+
raise ArgumentError, "Tuix project secret must be at least 32 bytes"
|
|
42
|
+
end
|
|
43
|
+
email = user[:email]
|
|
44
|
+
unless email.nil?
|
|
45
|
+
validate_text!(email, :email, 254)
|
|
46
|
+
email = email.strip.downcase
|
|
47
|
+
raise ArgumentError, "invalid Tuix email" unless email.match?(/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/)
|
|
48
|
+
end
|
|
49
|
+
message = email ? "#{id}\n#{email}" : id
|
|
50
|
+
result = { id: id, name: user[:name], hash: OpenSSL::HMAC.hexdigest("SHA256", secret, message) }
|
|
51
|
+
result[:email] = email if email
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.api_origin(value)
|
|
56
|
+
validate_text!(value, :api, 2048)
|
|
57
|
+
url = URI.parse(value)
|
|
58
|
+
unless url.is_a?(URI::HTTP) && url.host && !url.host.empty? && !url.userinfo && !url.query && !url.fragment &&
|
|
59
|
+
["", "/"].include?(url.path) && url.port.between?(1, 65_535) &&
|
|
60
|
+
(url.scheme == "https" || %w[localhost 127.0.0.1].include?(url.host))
|
|
61
|
+
raise ArgumentError, "Tuix API must be an HTTPS origin (HTTP is allowed on localhost)"
|
|
62
|
+
end
|
|
63
|
+
value.delete_suffix("/")
|
|
64
|
+
rescue URI::InvalidURIError
|
|
65
|
+
raise ArgumentError, "invalid Tuix API origin"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.payload(user, nonce: nil)
|
|
69
|
+
config = configuration
|
|
70
|
+
return nil if user.nil? || [config.project, config.secret, config.api].any? { |value| value.nil? || value == "" }
|
|
71
|
+
validate_text!(config.project, :project, 128)
|
|
72
|
+
unless config.environment.is_a?(String) && config.environment.match?(/\A[a-z][a-z0-9_-]{0,39}\z/)
|
|
73
|
+
raise ArgumentError, "invalid Tuix environment"
|
|
74
|
+
end
|
|
75
|
+
options = { project: config.project, api: api_origin(config.api), environment: config.environment, nonce: nonce }
|
|
76
|
+
options[:release] = validate_text!(config.release, :release, 200) if config.release
|
|
77
|
+
{ options: options, user: identity(user), delivery: "hosted" }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
require_relative "tuix_rails/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tuix_rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tuix
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: actionview
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9'
|
|
52
|
+
description: Server-signed Tuix identities, hosted widget delivery and Turbo lifecycle
|
|
53
|
+
integration for Rails applications.
|
|
54
|
+
executables: []
|
|
55
|
+
extensions: []
|
|
56
|
+
extra_rdoc_files: []
|
|
57
|
+
files:
|
|
58
|
+
- LICENSE
|
|
59
|
+
- README.md
|
|
60
|
+
- assets/bootstrap.js
|
|
61
|
+
- lib/generators/tuix/install_generator.rb
|
|
62
|
+
- lib/tuix_rails.rb
|
|
63
|
+
- lib/tuix_rails/assets.rb
|
|
64
|
+
- lib/tuix_rails/helper.rb
|
|
65
|
+
- lib/tuix_rails/railtie.rb
|
|
66
|
+
- lib/tuix_rails/version.rb
|
|
67
|
+
homepage: https://tuix.app
|
|
68
|
+
licenses:
|
|
69
|
+
- MIT
|
|
70
|
+
metadata:
|
|
71
|
+
homepage_uri: https://tuix.app
|
|
72
|
+
source_code_uri: https://github.com/tuix-app/tuix/tree/main/packages/rails
|
|
73
|
+
documentation_uri: https://github.com/tuix-app/tuix/tree/main/packages/rails#readme
|
|
74
|
+
bug_tracker_uri: https://github.com/tuix-app/tuix/issues
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '3.2'
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.6.9
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: Signed Tuix feedback widget integration for Rails
|
|
93
|
+
test_files: []
|