trmnl_preview 0.8.10 → 0.9.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 +4 -0
- data/README.md +57 -0
- data/lib/trmnlp/app.rb +38 -0
- data/lib/trmnlp/config/plugin.rb +8 -6
- data/lib/trmnlp/config/project.rb +4 -3
- data/lib/trmnlp/context.rb +11 -1
- data/lib/trmnlp/oauth/client.rb +64 -0
- data/lib/trmnlp/oauth/pkce.rb +20 -0
- data/lib/trmnlp/oauth/provider.rb +47 -0
- data/lib/trmnlp/oauth/session.rb +68 -0
- data/lib/trmnlp/oauth/token_bundle.rb +34 -0
- data/lib/trmnlp/oauth/token_store.rb +34 -0
- data/lib/trmnlp/oauth.rb +8 -0
- data/lib/trmnlp/paths.rb +5 -0
- data/lib/trmnlp/poller.rb +17 -8
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -0
- data/trmnl_preview.gemspec +3 -0
- data/web/views/index.erb +6 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbbc567f2937e7dd1aabc75991df616728f88fa858d797384a4a63453265be5c
|
|
4
|
+
data.tar.gz: 1b1e543aa9ea1dddbb456f061f1538c22cc0edd849fc6faced3d67fa5f682677
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b0ab9feab22239ce5e2eaa6c9b2fc7d1f889927e267c14d83eb6ec02e60a973acb9194fee59cae709ea7640c43741186788588b33e9d76ac023a5d5f09280d4
|
|
7
|
+
data.tar.gz: c18a319c45dec945b5c8f71d56c47f4478b8a82bceaed429b475cce4f57cb8a29541bcf7b1312e8fb9624513aebbd434d05c6e64f02d32322e9628cffc08553f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
|
|
2
2
|
# Changelog
|
|
3
3
|
|
|
4
|
+
## 0.9.0
|
|
5
|
+
|
|
6
|
+
- Added OAuth2 support for local plugin preview (beta). Configure a provider with the flat `oauth_*` keys in `src/settings.yml` (authorize and token URLs, scopes, optional PKCE), which round-trip through `trmnlp push` and `pull`. Set `TRMNL_OAUTH_CLIENT_ID` and `TRMNL_OAUTH_CLIENT_SECRET` in your environment (credentials stay local and are never synced), and register `http://localhost:4567/oauth/callback` as the redirect URI in your OAuth app. `trmnlp serve` shows a Connect banner that runs the authorization code flow in the browser, stores the tokens in the cache directory (never in the project), and refreshes them before they expire. The access token is exposed to polling templates as `{{ oauth_access_token }}`, matching the hosted service. This is a beta feature; please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
|
|
7
|
+
|
|
4
8
|
## 0.8.10
|
|
5
9
|
|
|
6
10
|
- Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3`, then `python`, then the `py` launcher) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116)
|
data/README.md
CHANGED
|
@@ -314,6 +314,63 @@ variables:
|
|
|
314
314
|
|
|
315
315
|
```
|
|
316
316
|
|
|
317
|
+
## OAuth2
|
|
318
|
+
|
|
319
|
+
This feature is in beta. Please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
|
|
320
|
+
|
|
321
|
+
Some private plugins fetch data from a third-party API that requires the user to authorize access first (the OAuth2 authorization code flow). `trmnlp` can run that flow locally so you can preview the plugin with a real token. It injects the token into your polling request the same way the hosted service does, so a plugin that works locally behaves the same once deployed.
|
|
322
|
+
|
|
323
|
+
### 1. Configure the provider
|
|
324
|
+
|
|
325
|
+
Add the flat `oauth_*` keys to `src/settings.yml`. These are the provider definition, and they round-trip through `trmnlp push` and `pull` (the hosted service stores them on the plugin setting):
|
|
326
|
+
|
|
327
|
+
```yaml
|
|
328
|
+
oauth_enabled: "true"
|
|
329
|
+
oauth_authorize_url: https://github.com/login/oauth/authorize
|
|
330
|
+
oauth_token_url: https://github.com/login/oauth/access_token
|
|
331
|
+
oauth_scopes: "read:user user:email"
|
|
332
|
+
oauth_pkce_enabled: "true" # optional, default false
|
|
333
|
+
# oauth_scope_separator: " " # optional; some providers use ","
|
|
334
|
+
# oauth_refresh_url: https://... # optional; defaults to oauth_token_url
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Your OAuth app credentials stay local and are never synced, so set them in your environment:
|
|
338
|
+
|
|
339
|
+
```sh
|
|
340
|
+
export TRMNL_OAUTH_CLIENT_ID=your-oauth-app-client-id
|
|
341
|
+
export TRMNL_OAUTH_CLIENT_SECRET=your-oauth-app-client-secret
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
PKCE-only providers do not need a client secret.
|
|
345
|
+
|
|
346
|
+
### 2. Register the redirect URI
|
|
347
|
+
|
|
348
|
+
In your OAuth app on the provider's site, register this redirect URI:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
http://localhost:4567/oauth/callback
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Match the port if you run `trmnlp serve` on a different one.
|
|
355
|
+
|
|
356
|
+
### 3. Connect
|
|
357
|
+
|
|
358
|
+
Run `trmnlp serve` and open the preview. When OAuth is configured but not yet connected, a **Connect account** banner appears. Click it to authorize in your browser. `trmnlp` stores the tokens in its cache directory (never in your project) and refreshes them automatically before they expire. Use the **Disconnect** link to reconnect after changing scopes.
|
|
359
|
+
|
|
360
|
+
### 4. Use the token
|
|
361
|
+
|
|
362
|
+
Reference the token in your `src/settings.yml` polling configuration with the same variables the hosted service exposes:
|
|
363
|
+
|
|
364
|
+
- `{{ oauth_access_token }}`
|
|
365
|
+
- `{{ oauth_token_type }}` (defaults to `Bearer`)
|
|
366
|
+
- `{{ oauth_client_id }}`
|
|
367
|
+
|
|
368
|
+
For example, as a polling header:
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
Authorization=Bearer {{ oauth_access_token }}
|
|
372
|
+
```
|
|
373
|
+
|
|
317
374
|
## Serverless Transforms
|
|
318
375
|
|
|
319
376
|
`trmnlp` can run a transform script (`python`, `ruby`, `php`, or `node`) against the polled API response before handing data to your Liquid templates — matching the hosted plugin service's behavior.
|
data/lib/trmnlp/app.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'securerandom'
|
|
3
4
|
require 'sinatra'
|
|
4
5
|
require 'sinatra/base'
|
|
5
6
|
|
|
@@ -33,6 +34,10 @@ module TRMNLP
|
|
|
33
34
|
def h(text)
|
|
34
35
|
Rack::Utils.escape_html(text.to_s)
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
# Derived from the live request so it matches at authorize and exchange
|
|
39
|
+
# time; this is the single URI the developer registers with the provider.
|
|
40
|
+
def oauth_callback_uri = "#{request.base_url}/oauth/callback"
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def initialize(*args)
|
|
@@ -44,6 +49,10 @@ module TRMNLP
|
|
|
44
49
|
@user_data_assembler = @context.user_data_assembler
|
|
45
50
|
@transform_pipeline = @context.transform_pipeline
|
|
46
51
|
@watcher = @context.watcher
|
|
52
|
+
@oauth_session = @context.oauth_session
|
|
53
|
+
# Keyed by state. A shared hash (built once) survives Sinatra's
|
|
54
|
+
# per-request dup, like @live_reload_clients below.
|
|
55
|
+
@oauth_state = {}
|
|
47
56
|
@screenshot = Screenshot.new(pool: settings.browser_pool)
|
|
48
57
|
|
|
49
58
|
@poller.poll_data
|
|
@@ -118,6 +127,35 @@ module TRMNLP
|
|
|
118
127
|
redirect back
|
|
119
128
|
end
|
|
120
129
|
|
|
130
|
+
get '/oauth/connect' do
|
|
131
|
+
halt 400, 'OAuth is not configured. Add the oauth_* keys to src/settings.yml.' unless @oauth_session.configured?
|
|
132
|
+
|
|
133
|
+
state = SecureRandom.hex(16)
|
|
134
|
+
if @oauth_session.pkce?
|
|
135
|
+
verifier = OAuth::Pkce.verifier
|
|
136
|
+
challenge = OAuth::Pkce.challenge(verifier)
|
|
137
|
+
end
|
|
138
|
+
@oauth_state[state] = verifier
|
|
139
|
+
redirect @oauth_session.authorize_url(redirect_uri: oauth_callback_uri, state:, code_challenge: challenge)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
get '/oauth/callback' do
|
|
143
|
+
halt 400, "OAuth provider returned an error: #{params[:error]}" if params[:error]
|
|
144
|
+
halt 400, 'OAuth state mismatch. Restart at /oauth/connect.' unless @oauth_state.key?(params[:state])
|
|
145
|
+
|
|
146
|
+
verifier = @oauth_state.delete(params[:state])
|
|
147
|
+
@oauth_session.complete(code: params[:code], redirect_uri: oauth_callback_uri, code_verifier: verifier)
|
|
148
|
+
@poller.poll_data
|
|
149
|
+
redirect '/'
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
halt 502, "OAuth token exchange failed: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
get '/oauth/disconnect' do
|
|
155
|
+
@oauth_session.disconnect
|
|
156
|
+
redirect '/'
|
|
157
|
+
end
|
|
158
|
+
|
|
121
159
|
Screen.all.each do |screen|
|
|
122
160
|
view = screen.name
|
|
123
161
|
get "/#{view}" do
|
data/lib/trmnlp/config/plugin.rb
CHANGED
|
@@ -30,14 +30,14 @@ module TRMNLP
|
|
|
30
30
|
def webhook? = strategy == 'webhook'
|
|
31
31
|
def static? = strategy == 'static'
|
|
32
32
|
|
|
33
|
-
def polling_urls
|
|
33
|
+
def polling_urls(extra_variables: {})
|
|
34
34
|
# allow project-level config to override
|
|
35
35
|
urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
|
|
36
36
|
'polling_url') || @config['polling_url']
|
|
37
37
|
|
|
38
38
|
return [] if urls.nil?
|
|
39
39
|
|
|
40
|
-
with_custom_fields(urls).strip.split("\n")
|
|
40
|
+
with_custom_fields(urls, extra_variables:).strip.split("\n")
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# for {{ trmnl }}
|
|
@@ -45,18 +45,18 @@ module TRMNLP
|
|
|
45
45
|
|
|
46
46
|
def polling_verb = @config['polling_verb'] || 'GET'
|
|
47
47
|
|
|
48
|
-
def polling_headers
|
|
48
|
+
def polling_headers(extra_variables: {})
|
|
49
49
|
# NOTE: render Liquid across the full headers string first so {% if %} blocks
|
|
50
50
|
# spanning multiple key=value pairs are preserved. Splitting on
|
|
51
51
|
# '&' or '=' before rendering would shatter tags into multiple values.
|
|
52
|
-
rendered = with_custom_fields(@config['polling_headers'] || '')
|
|
52
|
+
rendered = with_custom_fields(@config['polling_headers'] || '', extra_variables:)
|
|
53
53
|
string_to_hash(rendered)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# for {{ trmnl }}
|
|
57
57
|
def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
58
58
|
|
|
59
|
-
def polling_body = with_custom_fields(@config['polling_body'] || '')
|
|
59
|
+
def polling_body(extra_variables: {}) = with_custom_fields(@config['polling_body'] || '', extra_variables:)
|
|
60
60
|
|
|
61
61
|
def dark_mode = @config['dark_mode'] || 'no'
|
|
62
62
|
|
|
@@ -107,7 +107,9 @@ module TRMNLP
|
|
|
107
107
|
|
|
108
108
|
attr_reader :paths, :project_config
|
|
109
109
|
|
|
110
|
-
def with_custom_fields(value
|
|
110
|
+
def with_custom_fields(value, extra_variables: {})
|
|
111
|
+
project_config.with_custom_fields(value, extra_variables:)
|
|
112
|
+
end
|
|
111
113
|
|
|
112
114
|
def string_to_hash(str, delimiter: '=')
|
|
113
115
|
str.split('&').map do |k_v|
|
|
@@ -38,10 +38,11 @@ module TRMNLP
|
|
|
38
38
|
|
|
39
39
|
def user_data_overrides = @config['variables'] || {}
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
|
|
41
|
+
# extra_variables lets the poller inject live values (e.g.
|
|
42
|
+
# oauth_access_token) into the same custom-field render.
|
|
43
|
+
def with_custom_fields(value, extra_variables: {})
|
|
43
44
|
custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
|
|
44
|
-
parse_liquid(value).render(custom_fields_with_env)
|
|
45
|
+
parse_liquid(value).render(custom_fields_with_env.merge(extra_variables))
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def time_zone = @config['time_zone'] || 'UTC'
|
data/lib/trmnlp/context.rb
CHANGED
|
@@ -22,7 +22,17 @@ module TRMNLP
|
|
|
22
22
|
# Context is the composition root: it wires and memoizes the runtime
|
|
23
23
|
# object graph. Callers take the collaborator they need and talk to it
|
|
24
24
|
# directly — Context does not forward methods on their behalf.
|
|
25
|
-
def poller = @poller ||= Poller.new(config:, paths:, reporter:)
|
|
25
|
+
def poller = @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:)
|
|
26
|
+
|
|
27
|
+
def oauth_session
|
|
28
|
+
@oauth_session ||= begin
|
|
29
|
+
provider = OAuth::Provider.new(config.plugin.settings)
|
|
30
|
+
OAuth::Session.new(provider:,
|
|
31
|
+
token_store: OAuth::TokenStore.new(paths.oauth_tokens),
|
|
32
|
+
client: OAuth::Client.new(provider))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
|
|
27
37
|
def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
|
|
28
38
|
def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oauth2'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
module OAuth
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(provider)
|
|
10
|
+
@provider = provider
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authorize_url(redirect_uri:, state:, code_challenge: nil)
|
|
14
|
+
params = { redirect_uri:, scope: provider.scopes, state: }
|
|
15
|
+
params.merge!(code_challenge:, code_challenge_method: 'S256') if code_challenge
|
|
16
|
+
oauth_client(provider.token_url).auth_code.authorize_url(**params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exchange_code(code:, redirect_uri:, code_verifier: nil)
|
|
20
|
+
params = { redirect_uri: }
|
|
21
|
+
params[:code_verifier] = code_verifier if code_verifier
|
|
22
|
+
bundle oauth_client(provider.token_url).auth_code.get_token(code, params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def refresh(refresh_token:)
|
|
26
|
+
client = oauth_client(provider.refresh_url)
|
|
27
|
+
bundle OAuth2::AccessToken.new(client, nil, refresh_token:).refresh!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :provider
|
|
33
|
+
|
|
34
|
+
def oauth_client(token_url)
|
|
35
|
+
OAuth2::Client.new(
|
|
36
|
+
provider.client_id, provider.client_secret,
|
|
37
|
+
site: origin(token_url), authorize_url: provider.authorize_url,
|
|
38
|
+
token_url:, auth_scheme:
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A PKCE public client has no secret, so its client_id must travel in the
|
|
43
|
+
# request body; a confidential client authenticates with HTTP Basic.
|
|
44
|
+
def auth_scheme = provider.client_secret ? :basic_auth : :request_body
|
|
45
|
+
|
|
46
|
+
# oauth2 posts tokens through a connection built from the site, so give it
|
|
47
|
+
# the endpoint's origin even though the full URL is absolute.
|
|
48
|
+
def origin(url)
|
|
49
|
+
uri = URI(url)
|
|
50
|
+
port = uri.port == uri.default_port ? nil : ":#{uri.port}"
|
|
51
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def bundle(token)
|
|
55
|
+
TokenBundle.new(
|
|
56
|
+
access_token: token.token,
|
|
57
|
+
refresh_token: token.refresh_token,
|
|
58
|
+
expires_at: token.expires_at,
|
|
59
|
+
token_type: token.params['token_type'] || 'Bearer'
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'base64'
|
|
6
|
+
|
|
7
|
+
module TRMNLP
|
|
8
|
+
module OAuth
|
|
9
|
+
# Proof Key for Code Exchange (RFC 7636, S256).
|
|
10
|
+
module Pkce
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def verifier = SecureRandom.urlsafe_base64(64)
|
|
14
|
+
|
|
15
|
+
def challenge(verifier)
|
|
16
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
module OAuth
|
|
5
|
+
# Provider definition from the flat oauth_* keys in src/settings.yml (which
|
|
6
|
+
# round-trip through push/pull). Credentials stay local and env-first so
|
|
7
|
+
# they are never synced or committed.
|
|
8
|
+
class Provider
|
|
9
|
+
ENV_CLIENT_ID = 'TRMNL_OAUTH_CLIENT_ID'
|
|
10
|
+
ENV_CLIENT_SECRET = 'TRMNL_OAUTH_CLIENT_SECRET'
|
|
11
|
+
|
|
12
|
+
def initialize(settings, env: ENV)
|
|
13
|
+
@settings = settings || {}
|
|
14
|
+
@env = env
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorize_url = settings['oauth_authorize_url']
|
|
18
|
+
def token_url = settings['oauth_token_url']
|
|
19
|
+
def refresh_url = settings['oauth_refresh_url'] || token_url
|
|
20
|
+
def scopes = settings['oauth_scopes']
|
|
21
|
+
def scope_separator = settings['oauth_scope_separator'] || ' '
|
|
22
|
+
def pkce? = truthy?(settings['oauth_pkce_enabled'])
|
|
23
|
+
def enabled? = truthy?(settings['oauth_enabled'])
|
|
24
|
+
def client_id = env_first(ENV_CLIENT_ID, settings['oauth_client_id'])
|
|
25
|
+
def client_secret = env_first(ENV_CLIENT_SECRET, settings['oauth_client_secret'])
|
|
26
|
+
|
|
27
|
+
def configured?
|
|
28
|
+
return false unless enabled? && authorize_url && token_url && client_id
|
|
29
|
+
|
|
30
|
+
pkce? || present?(client_secret)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :settings, :env
|
|
36
|
+
|
|
37
|
+
def truthy?(value) = [true, 'true'].include?(value)
|
|
38
|
+
|
|
39
|
+
def env_first(key, fallback)
|
|
40
|
+
value = env[key]
|
|
41
|
+
present?(value) ? value : fallback
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def present?(value) = !value.nil? && !value.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
module OAuth
|
|
5
|
+
class RefreshError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class Session
|
|
8
|
+
def initialize(provider:, token_store:, client:)
|
|
9
|
+
@provider = provider
|
|
10
|
+
@token_store = token_store
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def configured? = provider.configured?
|
|
15
|
+
|
|
16
|
+
def pkce? = provider.pkce?
|
|
17
|
+
|
|
18
|
+
def connected?
|
|
19
|
+
return false unless configured?
|
|
20
|
+
|
|
21
|
+
stored = token_store.read
|
|
22
|
+
!stored.nil? && !stored.access_token.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def access_token = current_bundle&.access_token
|
|
26
|
+
|
|
27
|
+
def liquid_variables
|
|
28
|
+
return {} unless connected?
|
|
29
|
+
|
|
30
|
+
bundle = current_bundle
|
|
31
|
+
{
|
|
32
|
+
'oauth_access_token' => bundle.access_token,
|
|
33
|
+
'oauth_token_type' => bundle.token_type,
|
|
34
|
+
'oauth_client_id' => provider.client_id
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authorize_url(redirect_uri:, state:, code_challenge: nil)
|
|
39
|
+
client.authorize_url(redirect_uri:, state:, code_challenge:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def complete(code:, redirect_uri:, code_verifier: nil)
|
|
43
|
+
token_store.write(client.exchange_code(code:, redirect_uri:, code_verifier:))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def disconnect = token_store.clear
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :provider, :token_store, :client
|
|
51
|
+
|
|
52
|
+
# Returns the stored token, transparently refreshing and re-persisting it
|
|
53
|
+
# when it is near expiry. nil when nothing is connected yet.
|
|
54
|
+
def current_bundle
|
|
55
|
+
stored = token_store.read
|
|
56
|
+
return nil if stored.nil? || stored.access_token.nil?
|
|
57
|
+
|
|
58
|
+
stored.expired? ? refreshed(stored) : stored
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refreshed(stale)
|
|
62
|
+
token_store.write stale.merge_refresh(client.refresh(refresh_token: stale.refresh_token))
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise RefreshError, "OAuth token refresh failed: #{e.message}. Reconnect at /oauth/connect."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
module OAuth
|
|
5
|
+
TokenBundle = Data.define(:access_token, :refresh_token, :expires_at, :token_type) do
|
|
6
|
+
def self.from_h(hash)
|
|
7
|
+
new(
|
|
8
|
+
access_token: hash['access_token'],
|
|
9
|
+
refresh_token: hash['refresh_token'],
|
|
10
|
+
expires_at: hash['expires_at'],
|
|
11
|
+
token_type: hash['token_type'] || 'Bearer'
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def expired?
|
|
16
|
+
return false unless expires_at
|
|
17
|
+
|
|
18
|
+
# Treat as expired 5 minutes early so a token never dies mid-request.
|
|
19
|
+
expires_at.to_i - 300 <= Time.now.to_i
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A refresh response often omits the rotated refresh_token (and sometimes
|
|
23
|
+
# other fields); the prior values remain valid, so carry them forward.
|
|
24
|
+
def merge_refresh(fresh)
|
|
25
|
+
with(
|
|
26
|
+
access_token: fresh.access_token,
|
|
27
|
+
refresh_token: fresh.refresh_token || refresh_token,
|
|
28
|
+
expires_at: fresh.expires_at || expires_at,
|
|
29
|
+
token_type: fresh.token_type || token_type
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
module OAuth
|
|
7
|
+
class TokenStore
|
|
8
|
+
def initialize(path)
|
|
9
|
+
@path = path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def read
|
|
13
|
+
return nil unless path.exist?
|
|
14
|
+
|
|
15
|
+
TokenBundle.from_h(JSON.parse(path.read))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write(bundle)
|
|
19
|
+
path.dirname.mkpath
|
|
20
|
+
path.write(JSON.generate(bundle.to_h))
|
|
21
|
+
path.chmod(0o600)
|
|
22
|
+
bundle
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clear
|
|
26
|
+
path.delete if path.exist?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/trmnlp/oauth.rb
ADDED
data/lib/trmnlp/paths.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'xdg'
|
|
4
5
|
|
|
5
6
|
module TRMNLP
|
|
@@ -45,6 +46,10 @@ module TRMNLP
|
|
|
45
46
|
|
|
46
47
|
def user_data = cache_dir.join('data.json')
|
|
47
48
|
|
|
49
|
+
# OAuth tokens are keyed per project root so working on two OAuth plugins
|
|
50
|
+
# does not clobber a shared token file.
|
|
51
|
+
def oauth_tokens = cache_dir.join('oauth', "#{Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]}.json")
|
|
52
|
+
|
|
48
53
|
def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
|
|
49
54
|
|
|
50
55
|
def src_files = src_dir.glob('*').select(&:file?)
|
data/lib/trmnlp/poller.rb
CHANGED
|
@@ -8,9 +8,10 @@ require_relative 'reporter'
|
|
|
8
8
|
|
|
9
9
|
module TRMNLP
|
|
10
10
|
class Poller
|
|
11
|
-
def initialize(config:, paths:, reporter: Reporter.new)
|
|
11
|
+
def initialize(config:, paths:, oauth_session:, reporter: Reporter.new)
|
|
12
12
|
@config = config
|
|
13
13
|
@paths = paths
|
|
14
|
+
@oauth_session = oauth_session
|
|
14
15
|
@reporter = reporter
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -39,25 +40,33 @@ module TRMNLP
|
|
|
39
40
|
|
|
40
41
|
private
|
|
41
42
|
|
|
42
|
-
attr_reader :config, :paths, :reporter
|
|
43
|
+
attr_reader :config, :paths, :oauth_session, :reporter
|
|
43
44
|
|
|
44
45
|
def aggregate_responses
|
|
45
|
-
|
|
46
|
+
# Resolve once per poll (it refreshes as a side effect), then share across
|
|
47
|
+
# every URL, header, and body render.
|
|
48
|
+
oauth_variables = oauth_session.liquid_variables
|
|
49
|
+
urls = config.plugin.polling_urls(extra_variables: oauth_variables)
|
|
50
|
+
responses = urls.map { |url| fetch_one(url, oauth_variables) }
|
|
46
51
|
return responses.first if responses.size == 1
|
|
47
52
|
|
|
48
53
|
responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
|
|
49
54
|
end
|
|
50
55
|
|
|
51
|
-
def fetch_one(url)
|
|
56
|
+
def fetch_one(url, oauth_variables)
|
|
52
57
|
verb = config.plugin.polling_verb.upcase
|
|
53
|
-
response = perform_request(url, verb)
|
|
58
|
+
response = perform_request(url, verb, oauth_variables)
|
|
54
59
|
reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
|
|
55
60
|
parse_response(response)
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
def perform_request(url, verb)
|
|
59
|
-
conn = Faraday.new(url:, headers: config.plugin.polling_headers)
|
|
60
|
-
verb == 'POST'
|
|
63
|
+
def perform_request(url, verb, oauth_variables)
|
|
64
|
+
conn = Faraday.new(url:, headers: config.plugin.polling_headers(extra_variables: oauth_variables))
|
|
65
|
+
if verb == 'POST'
|
|
66
|
+
conn.post { |req| req.body = config.plugin.polling_body(extra_variables: oauth_variables) }
|
|
67
|
+
else
|
|
68
|
+
conn.get
|
|
69
|
+
end
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
def parse_response(response)
|
data/lib/trmnlp/version.rb
CHANGED
data/lib/trmnlp.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'trmnl/liquid'
|
|
|
6
6
|
Oj.mimic_JSON
|
|
7
7
|
TRMNL::Liquid::RailsHelpers = Module.new unless defined?(TRMNL::Liquid::RailsHelpers)
|
|
8
8
|
require_relative 'trmnlp/errors'
|
|
9
|
+
require_relative 'trmnlp/oauth'
|
|
9
10
|
require_relative 'trmnlp/config'
|
|
10
11
|
require_relative 'trmnlp/context'
|
|
11
12
|
require_relative 'trmnlp/screen'
|
data/trmnl_preview.gemspec
CHANGED
|
@@ -54,6 +54,9 @@ Gem::Specification.new do |spec|
|
|
|
54
54
|
spec.add_dependency 'mini_magick', '~> 5.3'
|
|
55
55
|
spec.add_dependency 'selenium-webdriver', '~> 4.44'
|
|
56
56
|
|
|
57
|
+
# OAuth2 (local plugin preview auth flow)
|
|
58
|
+
spec.add_dependency 'oauth2', '~> 2.0'
|
|
59
|
+
|
|
57
60
|
# Utilities
|
|
58
61
|
spec.add_dependency 'cgi', '~> 0.5'
|
|
59
62
|
spec.add_dependency 'faraday', '~> 2.1'
|
data/web/views/index.erb
CHANGED
|
@@ -60,6 +60,12 @@
|
|
|
60
60
|
</div>
|
|
61
61
|
</menu>
|
|
62
62
|
|
|
63
|
+
<% if @oauth_session&.connected? %>
|
|
64
|
+
<div class="oauth-banner" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #efe; border: 1px solid #3a3; border-radius: 4px; color: #060;">OAuth account connected (beta). <a href="/oauth/disconnect">Disconnect</a> · <a href="https://github.com/usetrmnl/trmnlp/issues" target="_blank" rel="noopener">Report an issue</a></div>
|
|
65
|
+
<% elsif @oauth_session&.configured? %>
|
|
66
|
+
<div class="oauth-banner" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #eef; border: 1px solid #66c; border-radius: 4px; color: #234;"><a href="/oauth/connect">Connect account</a> to authorize this plugin's OAuth requests. OAuth is in beta. <a href="https://github.com/usetrmnl/trmnlp/issues" target="_blank" rel="noopener">Report an issue</a>.</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
63
69
|
<% if @transform_error %>
|
|
64
70
|
<div class="transform-error" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #fee; border: 1px solid #c33; border-radius: 4px; color: #900; font-family: monospace; white-space: pre-wrap;">Transform error: <%= h @transform_error %></div>
|
|
65
71
|
<% end %>
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trmnl_preview
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rockwell Schrock
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - "~>"
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '4.44'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: oauth2
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.0'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.0'
|
|
110
124
|
- !ruby/object:Gem::Dependency
|
|
111
125
|
name: cgi
|
|
112
126
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -304,6 +318,13 @@ files:
|
|
|
304
318
|
- lib/trmnlp/lint/checks/title_length.rb
|
|
305
319
|
- lib/trmnlp/lint/checks/waits_for_dom_load.rb
|
|
306
320
|
- lib/trmnlp/lint/source.rb
|
|
321
|
+
- lib/trmnlp/oauth.rb
|
|
322
|
+
- lib/trmnlp/oauth/client.rb
|
|
323
|
+
- lib/trmnlp/oauth/pkce.rb
|
|
324
|
+
- lib/trmnlp/oauth/provider.rb
|
|
325
|
+
- lib/trmnlp/oauth/session.rb
|
|
326
|
+
- lib/trmnlp/oauth/token_bundle.rb
|
|
327
|
+
- lib/trmnlp/oauth/token_store.rb
|
|
307
328
|
- lib/trmnlp/paths.rb
|
|
308
329
|
- lib/trmnlp/poller.rb
|
|
309
330
|
- lib/trmnlp/renderer.rb
|