source_monitor 0.3.2 → 0.4.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/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +34 -0
- data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +49 -0
- data/.claude/commands/release.md +255 -0
- data/.claude/skills/sm-configure/SKILL.md +13 -2
- data/.claude/skills/sm-configure/reference/configuration-reference.md +33 -0
- data/.claude/skills/sm-host-setup/SKILL.md +21 -3
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +36 -0
- data/.claude/skills/sm-job/SKILL.md +10 -9
- data/.gitignore +4 -0
- data/.vbw-planning/REQUIREMENTS.md +22 -0
- data/.vbw-planning/ROADMAP.md +125 -0
- data/.vbw-planning/STATE.md +43 -0
- data/.vbw-planning/config.json +3 -1
- data/.vbw-planning/discovery.json +3 -1
- data/.vbw-planning/phases/01-generator-steps/01-CONTEXT.md +33 -0
- data/.vbw-planning/phases/01-generator-steps/01-VERIFICATION.md +86 -0
- data/.vbw-planning/phases/01-generator-steps/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/phases/01-generator-steps/PLAN-01.md +380 -0
- data/.vbw-planning/phases/02-verification/02-VERIFICATION.md +78 -0
- data/.vbw-planning/phases/02-verification/PLAN-01-SUMMARY.md +46 -0
- data/.vbw-planning/phases/02-verification/PLAN-01.md +500 -0
- data/.vbw-planning/phases/03-docs-alignment/03-VERIFICATION.md +89 -0
- data/.vbw-planning/phases/03-docs-alignment/PLAN-01-SUMMARY.md +48 -0
- data/.vbw-planning/phases/03-docs-alignment/PLAN-01.md +456 -0
- data/.vbw-planning/phases/04-dashboard-ux/04-VERIFICATION.md +129 -0
- data/.vbw-planning/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +70 -0
- data/.vbw-planning/phases/04-dashboard-ux/PLAN-01.md +747 -0
- data/.vbw-planning/phases/05-active-storage-images/05-VERIFICATION.md +156 -0
- data/.vbw-planning/phases/05-active-storage-images/PLAN-01-SUMMARY.md +69 -0
- data/.vbw-planning/phases/05-active-storage-images/PLAN-01.md +455 -0
- data/.vbw-planning/phases/05-active-storage-images/PLAN-02-SUMMARY.md +39 -0
- data/.vbw-planning/phases/05-active-storage-images/PLAN-02.md +488 -0
- data/.vbw-planning/phases/06-netflix-feed-fix/06-VERIFICATION.md +100 -0
- data/.vbw-planning/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +37 -0
- data/.vbw-planning/phases/06-netflix-feed-fix/PLAN-01.md +345 -0
- data/CHANGELOG.md +43 -0
- data/Gemfile.lock +1 -1
- data/VERSION +1 -1
- data/app/assets/builds/source_monitor/application.css +9 -0
- data/app/helpers/source_monitor/application_helper.rb +38 -0
- data/app/jobs/source_monitor/download_content_images_job.rb +72 -0
- data/app/models/source_monitor/item_content.rb +2 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +9 -0
- data/app/views/source_monitor/items/_details.html.erb +2 -2
- data/app/views/source_monitor/logs/index.html.erb +9 -0
- data/app/views/source_monitor/sources/_details.html.erb +2 -2
- data/app/views/source_monitor/sources/_row.html.erb +1 -1
- data/docs/setup.md +13 -4
- data/docs/troubleshooting.md +38 -7
- data/lib/generators/source_monitor/install/install_generator.rb +201 -0
- data/lib/source_monitor/configuration/http_settings.rb +7 -1
- data/lib/source_monitor/configuration/images_settings.rb +37 -0
- data/lib/source_monitor/configuration.rb +3 -1
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +16 -7
- data/lib/source_monitor/dashboard/recent_activity.rb +1 -0
- data/lib/source_monitor/dashboard/recent_activity_presenter.rb +15 -2
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +13 -0
- data/lib/source_monitor/http.rb +23 -0
- data/lib/source_monitor/images/content_rewriter.rb +81 -0
- data/lib/source_monitor/images/downloader.rb +82 -0
- data/lib/source_monitor/logs/table_presenter.rb +25 -0
- data/lib/source_monitor/setup/procfile_patcher.rb +31 -0
- data/lib/source_monitor/setup/queue_config_patcher.rb +84 -0
- data/lib/source_monitor/setup/verification/recurring_schedule_verifier.rb +102 -0
- data/lib/source_monitor/setup/verification/runner.rb +1 -1
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +1 -1
- data/lib/source_monitor/setup/workflow.rb +10 -0
- data/lib/source_monitor/version.rb +1 -1
- data/lib/source_monitor.rb +8 -0
- metadata +34 -3
- data/.vbw-planning/.notification-log.jsonl +0 -294
- data/.vbw-planning/.session-log.jsonl +0 -1376
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
---
|
|
2
|
+
phase: 6
|
|
3
|
+
plan: "01"
|
|
4
|
+
title: ssl-cert-store-configuration
|
|
5
|
+
type: execute
|
|
6
|
+
wave: 1
|
|
7
|
+
depends_on: []
|
|
8
|
+
cross_phase_deps: []
|
|
9
|
+
autonomous: true
|
|
10
|
+
effort_override: thorough
|
|
11
|
+
skills_used: []
|
|
12
|
+
files_modified:
|
|
13
|
+
- lib/source_monitor/configuration/http_settings.rb
|
|
14
|
+
- lib/source_monitor/http.rb
|
|
15
|
+
- test/lib/source_monitor/http_test.rb
|
|
16
|
+
- test/lib/source_monitor/fetching/feed_fetcher_test.rb
|
|
17
|
+
- test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml
|
|
18
|
+
must_haves:
|
|
19
|
+
truths:
|
|
20
|
+
- "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/http_test.rb` exits 0 with 0 failures"
|
|
21
|
+
- "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher_test.rb` exits 0 with 0 failures"
|
|
22
|
+
- "Running `bin/rubocop lib/source_monitor/http.rb lib/source_monitor/configuration/http_settings.rb test/lib/source_monitor/http_test.rb test/lib/source_monitor/fetching/feed_fetcher_test.rb` exits 0 with no offenses"
|
|
23
|
+
- "Running `bin/rails test` (full suite) exits 0 with 0 failures"
|
|
24
|
+
- "`grep -r 'cert_store' lib/source_monitor/http.rb` returns at least one match"
|
|
25
|
+
- "`grep -r 'ssl_ca_file' lib/source_monitor/configuration/http_settings.rb` returns at least one match"
|
|
26
|
+
- "The VCR cassette at test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml exists and contains 'netflixtechblog'"
|
|
27
|
+
artifacts:
|
|
28
|
+
- path: "lib/source_monitor/http.rb"
|
|
29
|
+
provides: "SSL cert store configuration on Faraday connections"
|
|
30
|
+
contains: "cert_store"
|
|
31
|
+
- path: "lib/source_monitor/configuration/http_settings.rb"
|
|
32
|
+
provides: "Configurable SSL options (ca_file, ca_path, verify)"
|
|
33
|
+
contains: "ssl_ca_file"
|
|
34
|
+
- path: "test/lib/source_monitor/http_test.rb"
|
|
35
|
+
provides: "Tests for SSL configuration on HTTP client"
|
|
36
|
+
contains: "ssl"
|
|
37
|
+
- path: "test/lib/source_monitor/fetching/feed_fetcher_test.rb"
|
|
38
|
+
provides: "Netflix Tech Blog VCR regression test"
|
|
39
|
+
contains: "netflix"
|
|
40
|
+
- path: "test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml"
|
|
41
|
+
provides: "Recorded VCR cassette from real Netflix Tech Blog feed"
|
|
42
|
+
contains: "netflixtechblog"
|
|
43
|
+
key_links:
|
|
44
|
+
- from: "http.rb#configure_ssl"
|
|
45
|
+
to: "REQ-25"
|
|
46
|
+
via: "Configures Faraday SSL with system cert store to fix certificate verification failures"
|
|
47
|
+
- from: "http_settings.rb#ssl_ca_file"
|
|
48
|
+
to: "REQ-25"
|
|
49
|
+
via: "Exposes configurable SSL CA file/path for environments with non-standard cert locations"
|
|
50
|
+
- from: "feed_fetcher_test.rb#netflix_regression"
|
|
51
|
+
to: "REQ-25"
|
|
52
|
+
via: "VCR cassette proves Netflix Tech Blog feed parses successfully"
|
|
53
|
+
---
|
|
54
|
+
<objective>
|
|
55
|
+
Fix SSL certificate verification failures (like Netflix Tech Blog's "unable to get local issuer certificate") by configuring the Faraday HTTP client to use a properly initialized OpenSSL cert store with system default paths. Add configurable SSL options to HTTPSettings so users can override CA file/path in non-standard environments. Record a VCR cassette from the real Netflix Tech Blog feed as a regression test. REQ-25.
|
|
56
|
+
</objective>
|
|
57
|
+
<context>
|
|
58
|
+
@lib/source_monitor/http.rb -- The HTTP client module. Creates Faraday connections via `HTTP.client`. Currently configures timeouts, retry, gzip, follow-redirects, and headers -- but does NOT configure any SSL options. Faraday's `connection.ssl` is left at defaults, which means it relies on the underlying adapter (net_http) to find CA certs. On some systems (macOS with Homebrew Ruby, Docker Alpine, custom OpenSSL builds), the compiled-in `OpenSSL::X509::DEFAULT_CERT_FILE` path may not include all intermediate certificates -- causing "certificate verify failed" for sites like Netflix Tech Blog whose chain depends on intermediates served by the TLS handshake being validated against a complete CA bundle. The fix is to explicitly set `connection.ssl.cert_store` to an `OpenSSL::X509::Store` initialized with `set_default_paths`, which loads both `DEFAULT_CERT_FILE` and `DEFAULT_CERT_DIR`. Optionally, if the user configures `ssl_ca_file` or `ssl_ca_path`, those override the default store.
|
|
59
|
+
|
|
60
|
+
@lib/source_monitor/configuration/http_settings.rb -- The settings class for HTTP configuration. Has 11 `attr_accessor` fields for timeout, retry, proxy, headers, etc. New SSL settings (`ssl_ca_file`, `ssl_ca_path`, `ssl_verify`) should be added here following the same pattern. Default: `ssl_verify = true` (never disable verification), `ssl_ca_file = nil`, `ssl_ca_path = nil` (nil means use system defaults via cert_store).
|
|
61
|
+
|
|
62
|
+
@test/lib/source_monitor/http_test.rb -- 8 existing tests for the HTTP client. Tests inspect `@connection.builder.handlers`, `@connection.options`, and `@connection.headers`. New SSL tests should inspect `@connection.ssl.cert_store`, `@connection.ssl.verify`, and optionally `@connection.ssl.ca_file` when configured.
|
|
63
|
+
|
|
64
|
+
@test/lib/source_monitor/fetching/feed_fetcher_test.rb -- Existing tests use VCR cassettes for RSS, Atom, and JSON feeds (ruby-lang.org, W3C, json_sample). The Netflix regression test should follow the same pattern: `VCR.use_cassette("source_monitor/fetching/netflix_medium_rss")` with a source pointing at `https://netflixtechblog.com/feed`.
|
|
65
|
+
|
|
66
|
+
@test/vcr_cassettes/ -- Contains 3 existing cassettes (rss_success, atom_success, json_success). The Netflix cassette should be recorded with `VCR.use_cassette(..., record: :new_episodes)` during the first test run against the real feed (with WebMock allowing the Netflix host temporarily), then committed as a fixture for CI. This requires temporarily allowing net connect to netflixtechblog.com during recording.
|
|
67
|
+
|
|
68
|
+
@lib/source_monitor/fetching/feed_fetcher.rb -- Lines 84-85 already catch `Faraday::SSLError` and wrap it as `ConnectionError`. This error path will stop triggering once SSL is properly configured, but the error handling remains as a safety net for genuinely invalid certificates.
|
|
69
|
+
|
|
70
|
+
**Root cause analysis:**
|
|
71
|
+
The Netflix Tech Blog (Medium-hosted at netflixtechblog.com, IP 52.1.173.203) serves a TLS certificate chain that requires the client to have Amazon's intermediate CA in its trust store. Ruby's compiled-in `OpenSSL::X509::DEFAULT_CERT_FILE` may point to a cert bundle that is missing this intermediate, or the system's cert directory may not be indexed. By explicitly creating an `OpenSSL::X509::Store` with `set_default_paths` and assigning it to the Faraday connection's `ssl.cert_store`, we ensure Ruby loads all available system certificates -- which on a properly maintained system includes Amazon/AWS intermediates. This is the standard, general fix for SSL verification issues in Ruby HTTP clients.
|
|
72
|
+
|
|
73
|
+
**Key design decisions:**
|
|
74
|
+
1. Use `OpenSSL::X509::Store.new.tap(&:set_default_paths)` as the default cert store -- this is the most cross-platform approach
|
|
75
|
+
2. Add `ssl_ca_file` and `ssl_ca_path` as optional overrides in HTTPSettings -- when set, they configure `connection.ssl.ca_file` / `connection.ssl.ca_path` instead of using the cert store
|
|
76
|
+
3. Keep `ssl_verify = true` as default and do NOT add a way to disable verification globally -- security-first design
|
|
77
|
+
4. The cert store is created fresh per `HTTP.client` call (Faraday connections are short-lived and not shared across threads)
|
|
78
|
+
5. For recording the VCR cassette: use a dedicated recording script or a test with `record: :new_episodes` and temporarily permit net connect
|
|
79
|
+
</context>
|
|
80
|
+
<tasks>
|
|
81
|
+
<task type="auto">
|
|
82
|
+
<name>add-ssl-settings-to-http-settings</name>
|
|
83
|
+
<files>
|
|
84
|
+
lib/source_monitor/configuration/http_settings.rb
|
|
85
|
+
</files>
|
|
86
|
+
<action>
|
|
87
|
+
Add three new `attr_accessor` fields to `HTTPSettings` for SSL configuration:
|
|
88
|
+
|
|
89
|
+
1. `ssl_ca_file` -- Path to a CA certificate file (PEM format). When set, Faraday uses this instead of the default cert store. Default: `nil`.
|
|
90
|
+
2. `ssl_ca_path` -- Path to a directory of CA certificates. When set, Faraday uses this. Default: `nil`.
|
|
91
|
+
3. `ssl_verify` -- Whether to verify SSL certificates. Default: `true`. This exists for completeness but should almost never be set to `false`.
|
|
92
|
+
|
|
93
|
+
Add the three new fields to the `attr_accessor` list (after `retry_statuses`):
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
attr_accessor :timeout,
|
|
97
|
+
:open_timeout,
|
|
98
|
+
:max_redirects,
|
|
99
|
+
:user_agent,
|
|
100
|
+
:proxy,
|
|
101
|
+
:headers,
|
|
102
|
+
:retry_max,
|
|
103
|
+
:retry_interval,
|
|
104
|
+
:retry_interval_randomness,
|
|
105
|
+
:retry_backoff_factor,
|
|
106
|
+
:retry_statuses,
|
|
107
|
+
:ssl_ca_file,
|
|
108
|
+
:ssl_ca_path,
|
|
109
|
+
:ssl_verify
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
In `reset!`, add after `@retry_statuses = nil`:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
@ssl_ca_file = nil
|
|
116
|
+
@ssl_ca_path = nil
|
|
117
|
+
@ssl_verify = true
|
|
118
|
+
```
|
|
119
|
+
</action>
|
|
120
|
+
<verify>
|
|
121
|
+
Read `lib/source_monitor/configuration/http_settings.rb` and confirm: (a) all three new attr_accessors are present, (b) `reset!` initializes them with correct defaults, (c) `ssl_verify` defaults to `true`.
|
|
122
|
+
</verify>
|
|
123
|
+
<done>
|
|
124
|
+
HTTPSettings now has ssl_ca_file, ssl_ca_path, and ssl_verify configuration options with safe defaults.
|
|
125
|
+
</done>
|
|
126
|
+
</task>
|
|
127
|
+
<task type="auto">
|
|
128
|
+
<name>configure-faraday-ssl-cert-store</name>
|
|
129
|
+
<files>
|
|
130
|
+
lib/source_monitor/http.rb
|
|
131
|
+
</files>
|
|
132
|
+
<action>
|
|
133
|
+
Modify the `HTTP` module to configure SSL on every Faraday connection. Add a `require "openssl"` at the top of the file (after the existing requires).
|
|
134
|
+
|
|
135
|
+
In the `configure_request` method, add SSL configuration BEFORE the adapter line (`connection.adapter Faraday.default_adapter`):
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
configure_ssl(connection, settings)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Add a new private method `configure_ssl`:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
def configure_ssl(connection, settings)
|
|
145
|
+
connection.ssl.verify = settings.ssl_verify != false
|
|
146
|
+
|
|
147
|
+
if settings.ssl_ca_file
|
|
148
|
+
connection.ssl.ca_file = settings.ssl_ca_file
|
|
149
|
+
elsif settings.ssl_ca_path
|
|
150
|
+
connection.ssl.ca_path = settings.ssl_ca_path
|
|
151
|
+
else
|
|
152
|
+
connection.ssl.cert_store = default_cert_store
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def default_cert_store
|
|
157
|
+
OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The logic:
|
|
162
|
+
1. Always set `verify = true` unless explicitly configured to `false` (defense in depth).
|
|
163
|
+
2. If user specifies `ssl_ca_file`, use that (overrides cert store).
|
|
164
|
+
3. Else if user specifies `ssl_ca_path`, use that (overrides cert store).
|
|
165
|
+
4. Otherwise, create a fresh `OpenSSL::X509::Store` with `set_default_paths` -- this is the key fix that resolves the Netflix SSL error by loading all system CA certificates including intermediates.
|
|
166
|
+
|
|
167
|
+
Note: `ca_file` and `ca_path` take precedence over `cert_store` in Faraday/net_http, so we only set one path.
|
|
168
|
+
</action>
|
|
169
|
+
<verify>
|
|
170
|
+
Read `lib/source_monitor/http.rb` and confirm: (a) `require "openssl"` is present, (b) `configure_ssl` is called in `configure_request`, (c) the method creates an `OpenSSL::X509::Store` with `set_default_paths` as the default, (d) `ssl_ca_file` and `ssl_ca_path` override the store when set, (e) `ssl.verify` is always explicitly set. Run `bin/rubocop lib/source_monitor/http.rb` to confirm no offenses.
|
|
171
|
+
</verify>
|
|
172
|
+
<done>
|
|
173
|
+
The HTTP client now explicitly configures SSL with a proper cert store. By default, every Faraday connection gets an OpenSSL::X509::Store initialized with system default paths, which resolves certificate chain verification failures like the Netflix Tech Blog issue.
|
|
174
|
+
</done>
|
|
175
|
+
</task>
|
|
176
|
+
<task type="auto">
|
|
177
|
+
<name>add-ssl-unit-tests</name>
|
|
178
|
+
<files>
|
|
179
|
+
test/lib/source_monitor/http_test.rb
|
|
180
|
+
</files>
|
|
181
|
+
<action>
|
|
182
|
+
Add the following tests to `HTTPTest`:
|
|
183
|
+
|
|
184
|
+
1. **"configures SSL with default cert store"** -- Create a default client, assert `@connection.ssl.verify` is truthy, assert `@connection.ssl.cert_store` is an instance of `OpenSSL::X509::Store`, assert `@connection.ssl.ca_file` is nil (not overridden).
|
|
185
|
+
|
|
186
|
+
2. **"uses configured ssl_ca_file when set"** -- Configure `config.http.ssl_ca_file = "/path/to/custom/ca.pem"`, create a client, assert `connection.ssl.ca_file` equals the configured path, assert `connection.ssl.cert_store` is nil (ca_file takes precedence).
|
|
187
|
+
|
|
188
|
+
3. **"uses configured ssl_ca_path when set"** -- Configure `config.http.ssl_ca_path = "/path/to/certs"`, create a client, assert `connection.ssl.ca_path` equals the configured path.
|
|
189
|
+
|
|
190
|
+
4. **"ssl verify defaults to true"** -- Create a default client, assert `connection.ssl.verify` is `true`.
|
|
191
|
+
|
|
192
|
+
5. **"respects ssl_verify configuration"** -- Configure `config.http.ssl_verify = false`, create a client, assert `connection.ssl.verify` is `false`. (This tests the escape hatch exists, even though it should rarely be used.)
|
|
193
|
+
|
|
194
|
+
Add `require "openssl"` at the top of the test file if not already present.
|
|
195
|
+
|
|
196
|
+
Each test should follow the existing pattern: create a connection via `SourceMonitor::HTTP.client`, then inspect the `connection.ssl` object.
|
|
197
|
+
</action>
|
|
198
|
+
<verify>
|
|
199
|
+
Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/http_test.rb` and confirm all tests pass (8 existing + 5 new = 13 tests). Run `bin/rubocop test/lib/source_monitor/http_test.rb` and confirm no offenses.
|
|
200
|
+
</verify>
|
|
201
|
+
<done>
|
|
202
|
+
5 new SSL configuration tests added. All 13 HTTP client tests pass. The cert store, ca_file, ca_path, and verify options are all verified.
|
|
203
|
+
</done>
|
|
204
|
+
</task>
|
|
205
|
+
<task type="auto">
|
|
206
|
+
<name>record-netflix-vcr-cassette-and-regression-test</name>
|
|
207
|
+
<files>
|
|
208
|
+
test/lib/source_monitor/fetching/feed_fetcher_test.rb
|
|
209
|
+
test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml
|
|
210
|
+
</files>
|
|
211
|
+
<action>
|
|
212
|
+
This task records a VCR cassette from the real Netflix Tech Blog feed and adds a regression test.
|
|
213
|
+
|
|
214
|
+
**Step 1: Record the VCR cassette.**
|
|
215
|
+
|
|
216
|
+
Create a temporary recording script or use a one-off test run. The simplest approach: add the test first (below), then run it once with `VCR_RECORD=new_episodes` or equivalent to record the cassette. The cassette will be committed as a test fixture.
|
|
217
|
+
|
|
218
|
+
To record, temporarily allow net connect for the Netflix host. You can do this by running:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher_test.rb -n test_fetches_netflix_tech_blog_feed_via_medium_rss
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
with VCR configured to `record: :new_episodes` for this specific cassette. If WebMock blocks the request, temporarily use `WebMock.allow_net_connect!` inside the test during recording, then remove it after the cassette is committed.
|
|
225
|
+
|
|
226
|
+
**Alternative recording approach:** Use a standalone Ruby script to fetch the feed and manually create the VCR cassette YAML:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
require "faraday"
|
|
230
|
+
require "openssl"
|
|
231
|
+
require "yaml"
|
|
232
|
+
|
|
233
|
+
conn = Faraday.new do |f|
|
|
234
|
+
f.ssl.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
235
|
+
f.request :gzip
|
|
236
|
+
f.response :follow_redirects, limit: 5
|
|
237
|
+
f.adapter :net_http
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
response = conn.get("https://netflixtechblog.com/feed")
|
|
241
|
+
# Save as VCR cassette format...
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
After recording, verify the cassette file exists at `test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml` and contains a 200 response with RSS/XML body containing Netflix blog entries.
|
|
245
|
+
|
|
246
|
+
**Step 2: Add the regression test.**
|
|
247
|
+
|
|
248
|
+
Add a new test to `FeedFetcherTest`:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
test "fetches Netflix Tech Blog feed via Medium RSS" do
|
|
252
|
+
source = build_source(
|
|
253
|
+
name: "Netflix Tech Blog",
|
|
254
|
+
feed_url: "https://netflixtechblog.com/feed"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
result = nil
|
|
258
|
+
VCR.use_cassette("source_monitor/fetching/netflix_medium_rss") do
|
|
259
|
+
result = FeedFetcher.new(source: source, jitter: ->(_) { 0 }).call
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
assert_equal :fetched, result.status
|
|
263
|
+
assert_not_nil result.feed
|
|
264
|
+
assert_kind_of Feedjira::Parser::RSS, result.feed
|
|
265
|
+
assert result.feed.entries.any?, "Expected at least one feed entry"
|
|
266
|
+
assert_match(/netflix/i, result.feed.title.to_s)
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
This test uses the recorded VCR cassette so it works in CI without network access. It validates that the feed parses as RSS and contains Netflix entries.
|
|
271
|
+
|
|
272
|
+
**Important:** The `build_source` helper is already available in this test file. Check existing test patterns to confirm the helper signature.
|
|
273
|
+
</action>
|
|
274
|
+
<verify>
|
|
275
|
+
Confirm: (a) the VCR cassette file exists at `test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml`, (b) it contains `netflixtechblog` in the request URI, (c) the response status is 200, (d) the response body contains RSS/XML content. Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher_test.rb -n test_fetches_Netflix_Tech_Blog_feed_via_Medium_RSS` and confirm it passes.
|
|
276
|
+
</verify>
|
|
277
|
+
<done>
|
|
278
|
+
VCR cassette recorded from real Netflix Tech Blog feed. Regression test passes using the cassette. The feed parses as RSS with Netflix blog entries, proving the SSL fix resolves the original "certificate verify failed" error.
|
|
279
|
+
</done>
|
|
280
|
+
</task>
|
|
281
|
+
<task type="auto">
|
|
282
|
+
<name>full-suite-verification-and-documentation</name>
|
|
283
|
+
<files>
|
|
284
|
+
lib/source_monitor/http.rb
|
|
285
|
+
lib/source_monitor/configuration/http_settings.rb
|
|
286
|
+
test/lib/source_monitor/http_test.rb
|
|
287
|
+
test/lib/source_monitor/fetching/feed_fetcher_test.rb
|
|
288
|
+
</files>
|
|
289
|
+
<action>
|
|
290
|
+
Run the full verification suite:
|
|
291
|
+
|
|
292
|
+
1. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/http_test.rb test/lib/source_monitor/fetching/feed_fetcher_test.rb` -- all targeted tests pass
|
|
293
|
+
2. `bin/rails test` -- full suite passes with 874+ runs and 0 failures
|
|
294
|
+
3. `bin/rubocop` -- zero offenses
|
|
295
|
+
4. `bin/brakeman --no-pager` -- zero warnings
|
|
296
|
+
|
|
297
|
+
Review all modified files for:
|
|
298
|
+
- `http.rb`: `require "openssl"` present, `configure_ssl` called in `configure_request`, `default_cert_store` creates `OpenSSL::X509::Store` with `set_default_paths`
|
|
299
|
+
- `http_settings.rb`: three new attr_accessors (`ssl_ca_file`, `ssl_ca_path`, `ssl_verify`), initialized in `reset!`
|
|
300
|
+
- `http_test.rb`: 5 new SSL tests covering cert_store default, ca_file override, ca_path override, verify default, verify override
|
|
301
|
+
- `feed_fetcher_test.rb`: Netflix regression test using VCR cassette
|
|
302
|
+
- VCR cassette: valid YAML with Netflix feed content
|
|
303
|
+
|
|
304
|
+
If any failures or offenses are found, fix them before completing.
|
|
305
|
+
|
|
306
|
+
Add a brief inline comment in `http.rb` above `configure_ssl` documenting the root cause:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
# Configure SSL to use a proper cert store. Without this, some systems
|
|
310
|
+
# fail to verify certificate chains that depend on intermediate CAs
|
|
311
|
+
# (e.g., Medium/Netflix on AWS). OpenSSL::X509::Store#set_default_paths
|
|
312
|
+
# loads all system-trusted CAs including intermediates.
|
|
313
|
+
```
|
|
314
|
+
</action>
|
|
315
|
+
<verify>
|
|
316
|
+
`bin/rails test` exits 0 with 874+ runs, 0 failures. `bin/rubocop` exits 0 with 0 offenses. `bin/brakeman --no-pager` exits 0 with 0 warnings. All modified files are clean and well-documented.
|
|
317
|
+
</verify>
|
|
318
|
+
<done>
|
|
319
|
+
Full suite passes. All quality gates green. SSL cert store fix is general (not Netflix-specific), configurable via HTTPSettings, documented inline, and regression-tested with a VCR cassette.
|
|
320
|
+
</done>
|
|
321
|
+
</task>
|
|
322
|
+
</tasks>
|
|
323
|
+
<verification>
|
|
324
|
+
1. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/http_test.rb` -- 13+ tests pass (8 existing + 5 new SSL tests)
|
|
325
|
+
2. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/fetching/feed_fetcher_test.rb` -- all tests pass including Netflix regression
|
|
326
|
+
3. `bin/rails test` -- 874+ runs, 0 failures
|
|
327
|
+
4. `bin/rubocop` -- 0 offenses
|
|
328
|
+
5. `bin/brakeman --no-pager` -- 0 warnings
|
|
329
|
+
6. `grep -n 'cert_store' lib/source_monitor/http.rb` returns matches for configure_ssl and default_cert_store
|
|
330
|
+
7. `grep -n 'ssl_ca_file' lib/source_monitor/configuration/http_settings.rb` returns match in attr_accessor and reset!
|
|
331
|
+
8. `test -f test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml` exits 0
|
|
332
|
+
9. `grep 'netflixtechblog' test/vcr_cassettes/source_monitor/fetching/netflix_medium_rss.yml` returns matches
|
|
333
|
+
</verification>
|
|
334
|
+
<success_criteria>
|
|
335
|
+
- Root cause identified: missing intermediate CA certs when OpenSSL cert store not explicitly initialized (REQ-25)
|
|
336
|
+
- General fix applied: Faraday SSL configured with OpenSSL::X509::Store#set_default_paths on every connection (REQ-25)
|
|
337
|
+
- Configurable: ssl_ca_file, ssl_ca_path, ssl_verify exposed via HTTPSettings for non-standard environments (REQ-25)
|
|
338
|
+
- Netflix Tech Blog feed fetches successfully via VCR cassette regression test (REQ-25)
|
|
339
|
+
- No regressions: existing SSL error wrapping (Faraday::SSLError -> ConnectionError) still works (REQ-25)
|
|
340
|
+
- VCR cassette recorded from real Netflix feed and committed as test fixture (REQ-25)
|
|
341
|
+
- All tests pass, RuboCop clean, Brakeman clean (REQ-25)
|
|
342
|
+
</success_criteria>
|
|
343
|
+
<output>
|
|
344
|
+
.vbw-planning/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md
|
|
345
|
+
</output>
|
data/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,49 @@ All notable changes to this project are documented below. The format follows [Ke
|
|
|
15
15
|
|
|
16
16
|
- No unreleased changes yet.
|
|
17
17
|
|
|
18
|
+
## [0.4.0] - 2026-02-12
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Install generator now auto-patches `Procfile.dev` with Solid Queue `jobs:` entry and `queue.yml` with `recurring_schedule` dispatcher wiring (idempotent, skip if already present).
|
|
23
|
+
- `RecurringScheduleVerifier` checks that recurring tasks are registered with Solid Queue dispatchers; `SolidQueueVerifier` remediation now mentions `Procfile.dev` for `bin/dev` users.
|
|
24
|
+
- Dashboard fetch log entries display source URL (domain for RSS, item URL for scrapes) alongside existing summary.
|
|
25
|
+
- External links across dashboard, logs, sources, and items open in new tab with visual indicator icon.
|
|
26
|
+
- Configurable Active Storage image downloads: `config.images.download_to_active_storage` (default `false`) detects inline images in feed content, downloads them via background job, and rewrites `<img>` src attributes with Active Storage URLs.
|
|
27
|
+
- `Images::ContentRewriter` extracts and rewrites image URLs from HTML content using Nokolexbor.
|
|
28
|
+
- `Images::Downloader` service validates content type and size before downloading images.
|
|
29
|
+
- `DownloadContentImagesJob` orchestrates the download/attach/rewrite pipeline per item.
|
|
30
|
+
- SSL certificate store configuration: every Faraday connection gets an `OpenSSL::X509::Store` initialized with `set_default_paths`, resolving "unable to get local issuer certificate" errors on systems with incomplete CA bundles.
|
|
31
|
+
- Configurable SSL options in `HTTPSettings`: `ssl_ca_file`, `ssl_ca_path`, `ssl_verify` for non-standard certificate environments.
|
|
32
|
+
- Netflix Tech Blog VCR cassette regression test proving Medium-hosted RSS feeds parse correctly with the SSL fix.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- SSL certificate verification failures for feeds hosted on services requiring intermediate CAs (e.g., Netflix Tech Blog via Medium/AWS).
|
|
37
|
+
- Setup documentation now includes `Procfile.dev` and `recurring_schedule` guidance.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Updated `sm-host-setup`, `sm-configure`, and setup documentation to reflect that the generator handles Procfile.dev and recurring_schedule automatically.
|
|
42
|
+
|
|
43
|
+
### Testing
|
|
44
|
+
|
|
45
|
+
- 973 tests, 3,114 assertions, 0 failures (up from 841 tests in 0.3.3).
|
|
46
|
+
- RuboCop: 389 files, 0 offenses.
|
|
47
|
+
- Brakeman: 0 warnings.
|
|
48
|
+
|
|
49
|
+
## [0.3.3] - 2026-02-11
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Added missing `recurring.yml` configuration to the install generator so host apps get Solid Queue recurring job config on install.
|
|
54
|
+
- Fixed YAML alias parsing in install generator so merging into existing `recurring.yml` files with `<<: *default` anchors works correctly.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Updated `sm-host-setup` and `sm-job` skills to reflect latest conventions.
|
|
59
|
+
- Updated setup documentation with current installation steps.
|
|
60
|
+
|
|
18
61
|
## [0.3.2] - 2026-02-10
|
|
19
62
|
|
|
20
63
|
### Fixed
|
data/Gemfile.lock
CHANGED
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.0
|
|
@@ -705,6 +705,10 @@ video {
|
|
|
705
705
|
margin-right: 0.25rem;
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
+
.fm-admin .mt-0\.5 {
|
|
709
|
+
margin-top: 0.125rem;
|
|
710
|
+
}
|
|
711
|
+
|
|
708
712
|
.fm-admin .mt-1 {
|
|
709
713
|
margin-top: 0.25rem;
|
|
710
714
|
}
|
|
@@ -1789,6 +1793,11 @@ video {
|
|
|
1789
1793
|
--tw-ring-color: transparent;
|
|
1790
1794
|
}
|
|
1791
1795
|
|
|
1796
|
+
.fm-admin .invert {
|
|
1797
|
+
--tw-invert: invert(100%);
|
|
1798
|
+
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1792
1801
|
.fm-admin .filter {
|
|
1793
1802
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1794
1803
|
}
|
|
@@ -212,8 +212,46 @@ module SourceMonitor
|
|
|
212
212
|
end
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
+
# Renders a clickable link that opens in a new tab with an external-link icon.
|
|
216
|
+
# Returns the label as plain text if the URL is blank.
|
|
217
|
+
def external_link_to(label, url, **options)
|
|
218
|
+
return label if url.blank?
|
|
219
|
+
|
|
220
|
+
css = options.delete(:class) || "text-blue-600 hover:text-blue-500"
|
|
221
|
+
link_to(url, target: "_blank", rel: "noopener noreferrer", class: css, title: url, **options) do
|
|
222
|
+
safe_join([ label, " ", external_link_icon ])
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Extracts the domain from a URL, returning nil if parsing fails.
|
|
227
|
+
def domain_from_url(url)
|
|
228
|
+
return nil if url.blank?
|
|
229
|
+
|
|
230
|
+
URI.parse(url.to_s).host
|
|
231
|
+
rescue URI::InvalidURIError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
215
235
|
private
|
|
216
236
|
|
|
237
|
+
def external_link_icon
|
|
238
|
+
tag.svg(
|
|
239
|
+
class: "inline-block h-3 w-3 text-slate-400",
|
|
240
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
241
|
+
fill: "none",
|
|
242
|
+
viewBox: "0 0 24 24",
|
|
243
|
+
stroke_width: "2",
|
|
244
|
+
stroke: "currentColor",
|
|
245
|
+
aria: { hidden: "true" }
|
|
246
|
+
) do
|
|
247
|
+
tag.path(
|
|
248
|
+
stroke_linecap: "round",
|
|
249
|
+
stroke_linejoin: "round",
|
|
250
|
+
d: "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
217
255
|
def derive_item_scrape_status(item:, source: nil)
|
|
218
256
|
return "idle" unless item
|
|
219
257
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class DownloadContentImagesJob < ApplicationJob
|
|
5
|
+
source_monitor_queue :fetch
|
|
6
|
+
|
|
7
|
+
discard_on ActiveJob::DeserializationError
|
|
8
|
+
|
|
9
|
+
def perform(item_id)
|
|
10
|
+
item = SourceMonitor::Item.find_by(id: item_id)
|
|
11
|
+
return unless item
|
|
12
|
+
return unless SourceMonitor.config.images.download_enabled?
|
|
13
|
+
|
|
14
|
+
html = item.content
|
|
15
|
+
return if html.blank?
|
|
16
|
+
|
|
17
|
+
# Build or find item_content for attachment storage
|
|
18
|
+
item_content = item.item_content || item.build_item_content
|
|
19
|
+
|
|
20
|
+
# Skip if images already attached (idempotency)
|
|
21
|
+
return if item_content.persisted? && item_content.images.attached?
|
|
22
|
+
|
|
23
|
+
base_url = item.url
|
|
24
|
+
rewriter = SourceMonitor::Images::ContentRewriter.new(html, base_url: base_url)
|
|
25
|
+
image_urls = rewriter.image_urls
|
|
26
|
+
return if image_urls.empty?
|
|
27
|
+
|
|
28
|
+
# Save item_content first so we can attach blobs to it
|
|
29
|
+
item_content.save! unless item_content.persisted?
|
|
30
|
+
|
|
31
|
+
# Download images and build URL mapping
|
|
32
|
+
url_mapping = download_images(item_content, image_urls)
|
|
33
|
+
return if url_mapping.empty?
|
|
34
|
+
|
|
35
|
+
# Rewrite HTML with Active Storage URLs
|
|
36
|
+
rewritten_html = rewriter.rewrite do |original_url|
|
|
37
|
+
url_mapping[original_url]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Update the item content with rewritten HTML
|
|
41
|
+
item.update!(content: rewritten_html)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def download_images(item_content, image_urls)
|
|
47
|
+
url_mapping = {}
|
|
48
|
+
settings = SourceMonitor.config.images
|
|
49
|
+
|
|
50
|
+
image_urls.each do |image_url|
|
|
51
|
+
result = SourceMonitor::Images::Downloader.new(image_url, settings: settings).call
|
|
52
|
+
next unless result
|
|
53
|
+
|
|
54
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
55
|
+
io: result.io,
|
|
56
|
+
filename: result.filename,
|
|
57
|
+
content_type: result.content_type
|
|
58
|
+
)
|
|
59
|
+
item_content.images.attach(blob)
|
|
60
|
+
|
|
61
|
+
# Generate a serving URL for the blob
|
|
62
|
+
url_mapping[image_url] = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
# Individual image failure should not block others.
|
|
65
|
+
# Original URL will be preserved (graceful fallback).
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
url_mapping
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
<div class="mt-1 text-xs text-slate-500">
|
|
22
22
|
<%= event[:description].presence || "No additional details recorded." %>
|
|
23
23
|
</div>
|
|
24
|
+
<% if event[:url_display].present? %>
|
|
25
|
+
<div class="mt-0.5 text-xs text-slate-400 truncate max-w-sm" data-testid="event-url-display">
|
|
26
|
+
<% if event[:url_href].present? %>
|
|
27
|
+
<%= external_link_to event[:url_display], event[:url_href], class: "text-slate-400 hover:text-blue-500" %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<%= event[:url_display] %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
24
33
|
</div>
|
|
25
34
|
<div class="text-right">
|
|
26
35
|
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= event[:status] == :success ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>">
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
<% details = {
|
|
54
54
|
"GUID" => item.guid,
|
|
55
55
|
"Content Fingerprint" => item.content_fingerprint || "—",
|
|
56
|
-
"URL" => item.url,
|
|
57
|
-
"Canonical URL" => item.canonical_url
|
|
56
|
+
"URL" => (item.url.present? ? external_link_to(item.url, item.url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
|
|
57
|
+
"Canonical URL" => (item.canonical_url.present? ? external_link_to(item.canonical_url, item.canonical_url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
|
|
58
58
|
"Author" => item.author || "—",
|
|
59
59
|
"Published At" => (item.published_at&.strftime("%b %d, %Y %H:%M %Z") || "—"),
|
|
60
60
|
"Updated At (Source)" => (item.updated_at_source&.strftime("%b %d, %Y %H:%M %Z") || "—"),
|
|
@@ -134,6 +134,15 @@
|
|
|
134
134
|
<% else %>
|
|
135
135
|
<%= row.primary_label %>
|
|
136
136
|
<% end %>
|
|
137
|
+
<% if row.url_label.present? %>
|
|
138
|
+
<div class="mt-0.5 text-xs text-slate-400 truncate max-w-xs">
|
|
139
|
+
<% if row.url_href.present? %>
|
|
140
|
+
<%= external_link_to row.url_label, row.url_href, class: "text-slate-400 hover:text-blue-500" %>
|
|
141
|
+
<% else %>
|
|
142
|
+
<%= row.url_label %>
|
|
143
|
+
<% end %>
|
|
144
|
+
</div>
|
|
145
|
+
<% end %>
|
|
137
146
|
</td>
|
|
138
147
|
<td class="px-6 py-4 text-sm">
|
|
139
148
|
<% if row.source_path %>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
</span>
|
|
26
26
|
<% end %>
|
|
27
27
|
</div>
|
|
28
|
-
<p class="mt-2 text-sm text-slate-500">Feed URL: <%= source.feed_url %></p>
|
|
28
|
+
<p class="mt-2 text-sm text-slate-500">Feed URL: <%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></p>
|
|
29
29
|
</div>
|
|
30
30
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
|
31
31
|
<% fetch_disabled = %w[queued fetching].include?(source.fetch_status) %>
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
details = {
|
|
140
|
-
"Website" => (source.website_url.
|
|
140
|
+
"Website" => (source.website_url.present? ? external_link_to(source.website_url, source.website_url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
|
|
141
141
|
"Fetch interval" => "#{source.fetch_interval_minutes} minutes (~#{interval_hours} hours)",
|
|
142
142
|
"Adaptive interval" => source.adaptive_fetching_enabled? ? "Auto" : "Fixed",
|
|
143
143
|
"Scraper" => source.scraper_adapter,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
class: "text-slate-900 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
|
30
30
|
data: { turbo_frame: "_top" } %>
|
|
31
31
|
</div>
|
|
32
|
-
<div class="text-xs text-slate-500 truncate max-w-xs"><%= source.feed_url %></div>
|
|
32
|
+
<div class="text-xs text-slate-500 truncate max-w-xs"><%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></div>
|
|
33
33
|
</td>
|
|
34
34
|
<td class="px-6 py-4">
|
|
35
35
|
<div class="flex flex-col gap-2 text-xs">
|