Skip to content

Commit 580542d

Browse files
committed
feat: simplify token-gated onboarding flow
1 parent df95f64 commit 580542d

13 files changed

Lines changed: 129 additions & 18 deletions

File tree

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ Run from the repository root:
3838
BUILD_TAG="$(date +%F)" \
3939
GIT_SHA="trial" \
4040
HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" \
41-
HEALTH_CHECK_TOKEN="$(openssl rand -hex 24)" \
42-
BROWSERLESS_IO_API_TOKEN="trial-browserless-token" \
41+
HTML2RSS_ACCESS_TOKEN="$(openssl rand -hex 24)" \
42+
AUTO_SOURCE_ENABLED=true \
4343
docker compose up -d
4444
```
4545

@@ -62,9 +62,13 @@ The checked-in [`docker-compose.yml`](docker-compose.yml) requires these environ
6262
- `BUILD_TAG`
6363
- `GIT_SHA`
6464
- `HTML2RSS_SECRET_KEY`
65-
- `HEALTH_CHECK_TOKEN`
6665
- `BROWSERLESS_IO_API_TOKEN`
6766

67+
For the simple page-URL onboarding path, also set:
68+
69+
- `HTML2RSS_ACCESS_TOKEN`
70+
- `AUTO_SOURCE_ENABLED=true`
71+
6872
Optional runtime variables:
6973

7074
- `SENTRY_DSN`
@@ -74,8 +78,8 @@ Example:
7478

7579
```bash
7680
export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)"
77-
export HEALTH_CHECK_TOKEN="replace-with-a-strong-token"
7881
export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token"
82+
export HTML2RSS_ACCESS_TOKEN="replace-with-a-strong-access-token"
7983
export BUILD_TAG="local"
8084
export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
8185
export AUTO_SOURCE_ENABLED=true
@@ -87,7 +91,7 @@ docker compose up -d
8791

8892
- In production, missing `HTML2RSS_SECRET_KEY` stops startup.
8993
- `BUILD_TAG` and `GIT_SHA` are expected in production; missing values produce a startup warning.
90-
- `POST /api/v1/feeds` requires a bearer token and only works when `AUTO_SOURCE_ENABLED=true`.
94+
- `POST /api/v1/feeds` requires a bearer token and only works when `AUTO_SOURCE_ENABLED=true`; the bundled `config/feeds.yml` reads the main token from `HTML2RSS_ACCESS_TOKEN` when it is set.
9195
- `AUTO_SOURCE_ENABLED` defaults to `true` in development/test and `false` otherwise.
9296
- Strategy support comes from `Html2rss::RequestService` (`faraday` and `browserless` availability is runtime-dependent).
9397

app/web/api/v1/health.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def bearer_token(request)
9494

9595
# @return [void]
9696
def verify_configuration!
97-
LocalConfig.yaml
97+
LocalConfig.snapshot
9898
rescue StandardError
9999
raise Html2rss::Web::HealthCheckFailedError
100100
end

app/web/config/environment_validator.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ module Web
66
# Environment validation for html2rss-web
77
# Handles validation of environment variables and configuration
88
module EnvironmentValidator # rubocop:disable Metrics/ModuleLength
9+
PLACEHOLDER_CREATE_FEED_TOKEN = 'CHANGE_ME_ADMIN_TOKEN'
10+
911
# rubocop:disable Metrics/ClassLength
1012
class << self
1113
##
@@ -105,12 +107,34 @@ def validate_build_metadata!
105107

106108
def validate_account_configuration!
107109
accounts = AccountManager.accounts
110+
validate_create_feed_token!(accounts)
108111
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
109112
return unless weak_tokens.any?
110113

111114
handle_weak_account_tokens!(weak_tokens)
112115
end
113116

117+
# @param accounts [Array<Hash{Symbol=>Object}>]
118+
# @return [void]
119+
def validate_create_feed_token!(accounts)
120+
return unless auto_source_enabled?
121+
122+
full_access_account = accounts.find do |account|
123+
account[:token] == PLACEHOLDER_CREATE_FEED_TOKEN && Array(account[:allowed_urls]).include?('*')
124+
end
125+
return unless full_access_account
126+
127+
SecurityLogger.log_config_validation_failure(
128+
'access_token',
129+
'Placeholder create-feed token is not allowed when auto source is enabled'
130+
)
131+
warn_lines(
132+
'CRITICAL: Placeholder create-feed token detected in production!',
133+
'Set HTML2RSS_ACCESS_TOKEN to a strong token before enabling automatic feed generation.'
134+
)
135+
exit 1
136+
end
137+
114138
# @param lines [Array<String>]
115139
# @return [void]
116140
def warn_lines(*lines)

app/web/config/local_config.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

3+
require 'erb'
34
require 'yaml'
5+
require_relative 'runtime_env'
46
begin
57
require 'html2rss/configs'
68
rescue LoadError => error
@@ -58,7 +60,7 @@ def global
5860
##
5961
# @return [Hash<Symbol, Any>]
6062
def yaml
61-
YAML.safe_load_file(CONFIG_FILE, symbolize_names: true).freeze
63+
YAML.safe_load(rendered_yaml, symbolize_names: true).freeze
6264
rescue Errno::ENOENT => error
6365
raise NotFound, "Configuration file not found: #{error.message}"
6466
end
@@ -82,6 +84,12 @@ def reload!(reason: 'manual')
8284

8385
private
8486

87+
# @return [String]
88+
def rendered_yaml
89+
template = File.read(CONFIG_FILE)
90+
ERB.new(template, trim_mode: '-').result
91+
end
92+
8593
# @param normalized_name [String]
8694
# @return [Hash{Symbol=>Object}, nil]
8795
def local_feed_config(normalized_name)

app/web/config/runtime_env.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module Web
66
# Captures boot-time environment configuration and scrubs selected secrets
77
# from the process environment after validation.
88
module RuntimeEnv
9-
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
9+
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HTML2RSS_ACCESS_TOKEN HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
1010
BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze
1111
@mutex = Mutex.new
1212
@values = nil
@@ -34,6 +34,11 @@ def health_check_token
3434
fetch('HEALTH_CHECK_TOKEN', '')
3535
end
3636

37+
# @return [String]
38+
def access_token
39+
fetch('HTML2RSS_ACCESS_TOKEN', '')
40+
end
41+
3742
# @return [String, nil]
3843
def sentry_dsn
3944
fetch('SENTRY_DSN', nil)

config/feeds.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
auth:
22
accounts:
33
- username: "admin"
4-
token: "CHANGE_ME_ADMIN_TOKEN"
4+
token: "<%= Html2rss::Web::RuntimeEnv.access_token.to_s.strip.empty? ? 'CHANGE_ME_ADMIN_TOKEN' : Html2rss::Web::RuntimeEnv.access_token %>"
55
allowed_urls:
66
- "*" # Full access
77
- username: "demo"

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ Managed flags and environment keys:
192192
| `auto_source_enabled` | `AUTO_SOURCE_ENABLED` | boolean | `true` in development/test, else `false` |
193193
| `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | boolean | `false` |
194194
| `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | integer `>= 1` | `3` |
195+
| `access_token` | `HTML2RSS_ACCESS_TOKEN` | string | `''` |
195196
| `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` |
196197
| `build_tag` | `BUILD_TAG` | string | `unknown` outside production |
197198
| `git_sha` | `GIT_SHA` | string | `unknown` outside production |

frontend/src/__tests__/App.test.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,14 @@ describe('App', () => {
191191
expect(screen.getByLabelText('Page URL')).toBeDisabled();
192192
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
193193
expect(screen.getByLabelText('Utilities')).toBeInTheDocument();
194-
expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument();
195-
expect(screen.getByText('Required by this instance.')).toBeInTheDocument();
194+
expect(
195+
screen.getByRole('link', {
196+
name: 'Copy `docker-compose.yml`, copy `.env`, start the stack, then paste the token.',
197+
})
198+
).toBeInTheDocument();
199+
expect(
200+
screen.getByText('Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup.')
201+
).toBeInTheDocument();
196202
expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument();
197203
await waitFor(() => {
198204
expect(document.activeElement).toBe(document.querySelector('#access-token'));
@@ -363,7 +369,7 @@ describe('App', () => {
363369
].map((element) => element.textContent);
364370

365371
expect(utilityItems).toEqual([
366-
'Try included feeds',
372+
'Browse included feeds',
367373
'Bookmarklet',
368374
'Logout',
369375
'Install from Docker Hub',
@@ -654,7 +660,7 @@ describe('App', () => {
654660
...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'),
655661
].map((link) => link.textContent);
656662
expect(utilityLinks).toEqual([
657-
'Try included feeds',
663+
'Browse included feeds',
658664
'Bookmarklet',
659665
'Install from Docker Hub',
660666
'OpenAPI spec',
@@ -665,7 +671,7 @@ describe('App', () => {
665671
'href',
666672
'http://example.test/openapi.yaml'
667673
);
668-
expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute(
674+
expect(screen.getByRole('link', { name: 'Browse included feeds' })).toHaveAttribute(
669675
'href',
670676
'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F'
671677
);

frontend/src/components/AppPanels.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ export function CreateFeedPanel({
157157
<div class="token-gate" role="group" aria-label="Access token">
158158
<div class="token-gate__copy">
159159
<h2>Enter access token</h2>
160-
<p class="token-gate__hint">Required by this instance.</p>
160+
<p class="token-gate__hint">
161+
Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup.
162+
</p>
161163
</div>
162164
<label class="field-block field-block--stretch field-block--compact" htmlFor="access-token">
163165
<span class="field-label field-label--ghost">Access token</span>
@@ -192,7 +194,7 @@ export function CreateFeedPanel({
192194
rel="noopener noreferrer"
193195
class="token-gate__nudge token-gate__nudge-link"
194196
>
195-
Set up your own instance with Docker.
197+
Copy `docker-compose.yml`, copy `.env`, start the stack, then paste the token.
196198
</a>
197199
<div class="token-gate__actions">
198200
<button type="button" class="btn btn--primary" onClick={onSaveToken}>
@@ -257,7 +259,7 @@ export function UtilityStrip({ hasAccessToken, openapiUrl, onClearToken }: Utili
257259
<section class="utility-strip" aria-label="Utilities">
258260
<div class="utility-strip__items">
259261
<a href={includedFeedsHref} target="_blank" rel="noopener noreferrer" class="utility-link">
260-
Try included feeds
262+
Browse included feeds
261263
</a>
262264
<Bookmarklet />
263265
{hasAccessToken && (

spec/html2rss/web/api/v1_spec.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ def expected_featured_feeds
288288

289289
it 'returns error when configuration fails', :aggregate_failures do
290290
allow(Html2rss::Web::Auth).to receive(:authenticate).and_return({ username: 'health-check' })
291-
allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'boom')
291+
allow(Html2rss::Web::LocalConfig).to receive(:snapshot).and_raise(StandardError, 'boom')
292292
header 'Authorization', "Bearer #{health_token}"
293293

294294
get '/api/v1/health'
@@ -318,6 +318,18 @@ def expected_featured_feeds
318318
json = expect_success_response(last_response)
319319
expect(json.dig('data', 'health', 'status')).to eq('healthy')
320320
end
321+
322+
it 'uses the effective runtime snapshot instead of reparsing raw yaml bytes', :aggregate_failures do
323+
allow(Html2rss::Web::LocalConfig).to receive(:snapshot)
324+
.and_return(Html2rss::Web::ConfigSnapshot::Snapshot.new(global: {}, feeds: {}, accounts: []))
325+
allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'stale raw yaml')
326+
327+
get '/api/v1/health/ready'
328+
329+
expect(last_response.status).to eq(200)
330+
json = expect_success_response(last_response)
331+
expect(json.dig('data', 'health', 'status')).to eq('healthy')
332+
end
321333
end
322334

323335
describe 'GET /api/v1/health/live', openapi: {

0 commit comments

Comments
 (0)