diff --git a/README.md b/README.md index 3516a8b3..b47dd374 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ Run from the repository root: BUILD_TAG="$(date +%F)" \ GIT_SHA="trial" \ HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" \ -HEALTH_CHECK_TOKEN="$(openssl rand -hex 24)" \ -BROWSERLESS_IO_API_TOKEN="trial-browserless-token" \ +HTML2RSS_ACCESS_TOKEN="$(openssl rand -hex 24)" \ +AUTO_SOURCE_ENABLED=true \ docker compose up -d ``` @@ -62,9 +62,13 @@ The checked-in [`docker-compose.yml`](docker-compose.yml) requires these environ - `BUILD_TAG` - `GIT_SHA` - `HTML2RSS_SECRET_KEY` -- `HEALTH_CHECK_TOKEN` - `BROWSERLESS_IO_API_TOKEN` +For the simple page-URL onboarding path, also set: + +- `HTML2RSS_ACCESS_TOKEN` +- `AUTO_SOURCE_ENABLED=true` + Optional runtime variables: - `SENTRY_DSN` @@ -74,8 +78,8 @@ Example: ```bash export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" -export HEALTH_CHECK_TOKEN="replace-with-a-strong-token" export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token" +export HTML2RSS_ACCESS_TOKEN="replace-with-a-strong-access-token" export BUILD_TAG="local" export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" export AUTO_SOURCE_ENABLED=true @@ -87,7 +91,7 @@ docker compose up -d - In production, missing `HTML2RSS_SECRET_KEY` stops startup. - `BUILD_TAG` and `GIT_SHA` are expected in production; missing values produce a startup warning. -- `POST /api/v1/feeds` requires a bearer token and only works when `AUTO_SOURCE_ENABLED=true`. +- `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. - `AUTO_SOURCE_ENABLED` defaults to `true` in development/test and `false` otherwise. - Strategy support comes from `Html2rss::RequestService` (`faraday` and `browserless` availability is runtime-dependent). diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb index 81f889ce..734d6214 100644 --- a/app/web/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -94,7 +94,7 @@ def bearer_token(request) # @return [void] def verify_configuration! - LocalConfig.yaml + LocalConfig.snapshot rescue StandardError raise Html2rss::Web::HealthCheckFailedError end diff --git a/app/web/config/environment_validator.rb b/app/web/config/environment_validator.rb index f651ceb1..b5365f44 100644 --- a/app/web/config/environment_validator.rb +++ b/app/web/config/environment_validator.rb @@ -6,6 +6,8 @@ module Web # Environment validation for html2rss-web # Handles validation of environment variables and configuration module EnvironmentValidator # rubocop:disable Metrics/ModuleLength + PLACEHOLDER_CREATE_FEED_TOKEN = 'CHANGE_ME_ADMIN_TOKEN' + # rubocop:disable Metrics/ClassLength class << self ## @@ -105,12 +107,34 @@ def validate_build_metadata! def validate_account_configuration! accounts = AccountManager.accounts + validate_create_feed_token!(accounts) weak_tokens = accounts.select { |acc| acc[:token].length < 16 } return unless weak_tokens.any? handle_weak_account_tokens!(weak_tokens) end + # @param accounts [ArrayObject}>] + # @return [void] + def validate_create_feed_token!(accounts) + return unless auto_source_enabled? + + full_access_account = accounts.find do |account| + account[:token] == PLACEHOLDER_CREATE_FEED_TOKEN && Array(account[:allowed_urls]).include?('*') + end + return unless full_access_account + + SecurityLogger.log_config_validation_failure( + 'access_token', + 'Placeholder create-feed token is not allowed when auto source is enabled' + ) + warn_lines( + 'CRITICAL: Placeholder create-feed token detected in production!', + 'Set HTML2RSS_ACCESS_TOKEN to a strong token before enabling automatic feed generation.' + ) + exit 1 + end + # @param lines [Array] # @return [void] def warn_lines(*lines) diff --git a/app/web/config/local_config.rb b/app/web/config/local_config.rb index 8810ccb5..a59bd5f8 100644 --- a/app/web/config/local_config.rb +++ b/app/web/config/local_config.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require 'erb' require 'yaml' +require_relative 'runtime_env' begin require 'html2rss/configs' rescue LoadError => error @@ -58,7 +60,7 @@ def global ## # @return [Hash] def yaml - YAML.safe_load_file(CONFIG_FILE, symbolize_names: true).freeze + YAML.safe_load(rendered_yaml, symbolize_names: true).freeze rescue Errno::ENOENT => error raise NotFound, "Configuration file not found: #{error.message}" end @@ -82,6 +84,12 @@ def reload!(reason: 'manual') private + # @return [String] + def rendered_yaml + template = File.read(CONFIG_FILE) + ERB.new(template, trim_mode: '-').result + end + # @param normalized_name [String] # @return [Hash{Symbol=>Object}, nil] def local_feed_config(normalized_name) diff --git a/app/web/config/runtime_env.rb b/app/web/config/runtime_env.rb index 6a2c6d5c..2d8613d2 100644 --- a/app/web/config/runtime_env.rb +++ b/app/web/config/runtime_env.rb @@ -6,7 +6,7 @@ module Web # Captures boot-time environment configuration and scrubs selected secrets # from the process environment after validation. module RuntimeEnv - SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze + SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HTML2RSS_ACCESS_TOKEN HEALTH_CHECK_TOKEN SENTRY_DSN].freeze BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze @mutex = Mutex.new @values = nil @@ -34,6 +34,11 @@ def health_check_token fetch('HEALTH_CHECK_TOKEN', '') end + # @return [String] + def access_token + fetch('HTML2RSS_ACCESS_TOKEN', '') + end + # @return [String, nil] def sentry_dsn fetch('SENTRY_DSN', nil) diff --git a/config/feeds.yml b/config/feeds.yml index c49a7422..202fa793 100644 --- a/config/feeds.yml +++ b/config/feeds.yml @@ -1,7 +1,7 @@ auth: accounts: - username: "admin" - token: "CHANGE_ME_ADMIN_TOKEN" + token: "<%= Html2rss::Web::RuntimeEnv.access_token.to_s.strip.empty? ? 'CHANGE_ME_ADMIN_TOKEN' : Html2rss::Web::RuntimeEnv.access_token %>" allowed_urls: - "*" # Full access - username: "demo" diff --git a/docs/README.md b/docs/README.md index f7d9c4cf..418fd9bd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -192,6 +192,7 @@ Managed flags and environment keys: | `auto_source_enabled` | `AUTO_SOURCE_ENABLED` | boolean | `true` in development/test, else `false` | | `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | boolean | `false` | | `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | integer `>= 1` | `3` | +| `access_token` | `HTML2RSS_ACCESS_TOKEN` | string | `''` | | `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` | | `build_tag` | `BUILD_TAG` | string | `unknown` outside production | | `git_sha` | `GIT_SHA` | string | `unknown` outside production | diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index e6d129a0..23360dbf 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -191,8 +191,14 @@ describe('App', () => { expect(screen.getByLabelText('Page URL')).toBeDisabled(); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument(); - expect(screen.getByText('Required by this instance.')).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: 'Copy `docker-compose.yml`, copy `.env`, start the stack, then paste the token.', + }) + ).toBeInTheDocument(); + expect( + screen.getByText('Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup.') + ).toBeInTheDocument(); expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument(); await waitFor(() => { expect(document.activeElement).toBe(document.querySelector('#access-token')); @@ -363,7 +369,7 @@ describe('App', () => { ].map((element) => element.textContent); expect(utilityItems).toEqual([ - 'Try included feeds', + 'Browse included feeds', 'Bookmarklet', 'Logout', 'Install from Docker Hub', @@ -654,7 +660,7 @@ describe('App', () => { ...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'), ].map((link) => link.textContent); expect(utilityLinks).toEqual([ - 'Try included feeds', + 'Browse included feeds', 'Bookmarklet', 'Install from Docker Hub', 'OpenAPI spec', @@ -665,7 +671,7 @@ describe('App', () => { 'href', 'http://example.test/openapi.yaml' ); - expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Browse included feeds' })).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 1807ed9a..67f16632 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -157,7 +157,9 @@ export function CreateFeedPanel({

Enter access token

-

Required by this instance.

+

+ Use the `HTML2RSS_ACCESS_TOKEN` from your instance `.env` or setup. +