From 2797de39baafd68d14ae678c1f93c04267d562ff Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Mon, 16 Mar 2026 18:54:45 +0100 Subject: [PATCH 01/16] feat: default to browserless in web --- app/web/api/v1/strategies.rb | 4 +- app/web/boot/setup.rb | 10 +++- frontend/src/__tests__/App.contract.test.tsx | 8 ++- frontend/src/__tests__/App.test.tsx | 51 ++++++++++++++++++-- frontend/src/__tests__/mocks/server.ts | 4 +- frontend/src/components/App.tsx | 38 ++++++++++++--- frontend/src/components/AppPanels.tsx | 6 ++- spec/html2rss/web/boot/setup_spec.rb | 13 ++++- 8 files changed, 115 insertions(+), 19 deletions(-) diff --git a/app/web/api/v1/strategies.rb b/app/web/api/v1/strategies.rb index 12582b08..91822fd6 100644 --- a/app/web/api/v1/strategies.rb +++ b/app/web/api/v1/strategies.rb @@ -33,8 +33,8 @@ def index(_request) def display_name_for(name) case name.to_s - when 'ssrf_filter' then 'Standard (recommended)' - when 'browserless' then 'JavaScript pages' + when 'ssrf_filter' then 'Standard rendering' + when 'browserless' then 'JavaScript pages (recommended)' else name.to_s.split('_').map(&:capitalize).join(' ') end end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index 2c22f364..f5657160 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -27,9 +27,17 @@ def validate_environment! # @return [void] def configure_request_service! Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) - Html2rss::RequestService.default_strategy_name = :ssrf_filter + Html2rss::RequestService.default_strategy_name = preferred_default_strategy Html2rss::RequestService.unregister_strategy(:faraday) end + + # @return [Symbol] + def preferred_default_strategy + supported = Html2rss::RequestService.strategy_names.map(&:to_sym) + return :browserless if supported.include?(:browserless) + + :ssrf_filter + end end end end diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index b4f08a7a..3db1701b 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -18,7 +18,7 @@ describe('App contract', () => { http.post('/api/v1/feeds', async ({ request }) => { const body = (await request.json()) as { url: string; strategy: string }; - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' }); + expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'browserless' }); expect(request.headers.get('authorization')).toBe(`Bearer ${token}`); return HttpResponse.json( @@ -47,6 +47,9 @@ describe('App contract', () => { render(); await screen.findByLabelText('Page URL'); + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('browserless'); + }); const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement; fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } }); @@ -135,6 +138,9 @@ describe('App contract', () => { render(); await screen.findByLabelText('Page URL'); + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('browserless'); + }); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index c5b44a7f..3ae65760 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -38,6 +38,7 @@ describe('App', () => { beforeEach(() => { vi.clearAllMocks(); + window.history.replaceState({}, '', 'http://localhost:3000/'); mockUseAccessToken.mockReturnValue({ token: null, @@ -77,8 +78,8 @@ describe('App', () => { mockUseStrategies.mockReturnValue({ strategies: [ - { id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard (recommended)' }, - { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages' }, + { id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard rendering' }, + { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, ], isLoading: false, error: null, @@ -102,6 +103,50 @@ describe('App', () => { }); }); + it('prefers browserless as the default strategy when available', () => { + render(); + + return waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('browserless'); + }); + }); + + it('falls back to the first available strategy when browserless is unavailable', () => { + mockUseStrategies.mockReturnValue({ + strategies: [{ id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard rendering' }], + isLoading: false, + error: null, + }); + + render(); + + return waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('ssrf_filter'); + }); + }); + + it('auto-submits a prefilled url using the resolved default strategy', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + window.history.replaceState({}, '', 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles'); + + render(); + + await waitFor(() => { + expect(mockConvertFeed).toHaveBeenCalledWith( + 'https://example.com/articles', + 'browserless', + 'saved-token' + ); + }); + }); + it('shows inline token prompt when submitting without a token', async () => { render(); @@ -196,7 +241,7 @@ describe('App', () => { expect(mockSaveToken).toHaveBeenCalledWith('token-123'); expect(mockConvertFeed).toHaveBeenCalledWith( 'https://example.com/articles', - 'ssrf_filter', + 'browserless', 'token-123' ); }); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 60098f03..3affee1c 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -28,12 +28,12 @@ export const server = setupServer( { id: 'ssrf_filter', name: 'ssrf_filter', - display_name: 'Standard (recommended)', + display_name: 'Standard rendering', }, { id: 'browserless', name: 'browserless', - display_name: 'JavaScript pages', + display_name: 'JavaScript pages (recommended)', }, ], }, diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 02ccdd75..c7d1f562 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { ResultDisplay } from './ResultDisplay'; import { CreateFeedPanel, UtilityStrip, type Strategy } from './AppPanels'; import { useAccessToken } from '../hooks/useAccessToken'; @@ -8,6 +8,8 @@ import { useStrategies } from '../hooks/useStrategies'; const EMPTY_FEED_ERRORS = { url: '', form: '' }; const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; +const preferredStrategy = (strategies: { id: string }[]) => + strategies.find((strategy) => strategy.id === 'browserless')?.id ?? strategies[0]?.id; function BrandLockup() { return ( @@ -42,25 +44,29 @@ export function App() { } = useFeedConversion(); const { strategies, isLoading: strategiesLoading, error: strategiesError } = useStrategies(); - const [feedFormData, setFeedFormData] = useState({ url: '', strategy: 'ssrf_filter' }); + const [feedFormData, setFeedFormData] = useState({ url: '', strategy: '' }); const [feedFieldErrors, setFeedFieldErrors] = useState(EMPTY_FEED_ERRORS); const [showTokenPrompt, setShowTokenPrompt] = useState(false); const [tokenDraft, setTokenDraft] = useState(''); const [tokenError, setTokenError] = useState(''); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); + const autoSubmitUrlRef = useRef(null); + const hasAutoSubmittedRef = useRef(false); + const selectedStrategy = feedFormData.strategy || preferredStrategy(strategies) || ''; useEffect(() => { if (typeof window === 'undefined') return; - if (feedFormData.url) return; const urlParam = new URLSearchParams(window.location.search).get('url'); if (!urlParam) return; + autoSubmitUrlRef.current = urlParam; + if (feedFormData.url) return; setFeedFormData((prev) => ({ ...prev, url: urlParam })); }, [feedFormData.url]); useEffect(() => { - const nextStrategy = strategies[0]?.id; + const nextStrategy = preferredStrategy(strategies); if (!nextStrategy) return; const hasCurrentStrategy = strategies.some((strategy) => strategy.id === feedFormData.strategy); @@ -68,6 +74,7 @@ export function App() { }, [strategies, feedFormData.strategy]); const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; + const submitDisabled = isConverting || strategiesLoading || !feedCreation.enabled || showTokenPrompt; const setFeedField = (key: 'url' | 'strategy', value: string) => { setFeedFormData((prev) => ({ ...prev, [key]: value })); @@ -96,11 +103,18 @@ export function App() { }; const attemptFeedCreation = async (accessToken: string) => { + const strategy = selectedStrategy; + if (!feedFormData.url.trim()) { setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: 'Source URL is required.' }); return false; } + if (!strategy) { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: 'Strategy is required' }); + return false; + } + if (!feedCreation.enabled) { setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, @@ -117,7 +131,7 @@ export function App() { } try { - await convertFeed(feedFormData.url, feedFormData.strategy, accessToken); + await convertFeed(feedFormData.url, strategy, accessToken); setShowTokenPrompt(false); setTokenError(''); return true; @@ -166,6 +180,17 @@ export function App() { setFocusCreateComposerKey((current) => current + 1); }; + useEffect(() => { + const autoSubmitUrl = autoSubmitUrlRef.current; + if (!autoSubmitUrl || hasAutoSubmittedRef.current) return; + if (strategiesLoading || metadataLoading || tokenLoading) return; + if (feedFormData.url !== autoSubmitUrl || !selectedStrategy) return; + + hasAutoSubmittedRef.current = true; + setFeedFieldErrors(EMPTY_FEED_ERRORS); + void attemptFeedCreation(token ?? ''); + }, [feedFormData.url, metadataLoading, selectedStrategy, strategiesLoading, token, tokenLoading]); + if (metadataLoading || tokenLoading) { return (
@@ -200,10 +225,11 @@ export function App() { <> (null); const strategyOptionLabel = (strategy: Strategy) => { if (strategy.id === 'ssrf_filter') return 'Standard rendering'; - if (strategy.id === 'browserless') return 'JavaScript pages'; + if (strategy.id === 'browserless') return 'JavaScript pages (recommended)'; return strategy.display_name; }; @@ -109,7 +111,7 @@ export function CreateFeedPanel({ inputRef={urlInputRef} actionLabel={isConverting ? 'Generating feed URL' : 'Generate feed URL'} actionText={isConverting ? '...' : '>'} - disabled={isConverting || !feedCreationEnabled || showTokenPrompt} + disabled={submitDisabled} error={feedFieldErrors.url} onInput={(event) => onFeedFieldChange('url', (event.target as HTMLInputElement).value)} /> diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index 32fbde0f..5505289d 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../../app/web/boot/setup' +require_relative '../../../../app' RSpec.describe Html2rss::Web::Boot::Setup do describe '.call!' do @@ -11,6 +11,7 @@ allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) allow(Html2rss::Web::Flags).to receive(:validate!) allow(Html2rss::RequestService).to receive(:register_strategy) + allow(Html2rss::RequestService).to receive(:strategy_names).and_return(%i[ssrf_filter browserless]) allow(Html2rss::RequestService).to receive(:default_strategy_name=) allow(Html2rss::RequestService).to receive(:unregister_strategy) end @@ -23,8 +24,16 @@ expect(Html2rss::Web::Flags).to have_received(:validate!).once expect(Html2rss::RequestService).to have_received(:register_strategy) .with(:ssrf_filter, Html2rss::Web::SsrfFilterStrategy).once - expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:ssrf_filter).once + expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:browserless).once expect(Html2rss::RequestService).to have_received(:unregister_strategy).with(:faraday).once end + + it 'falls back to ssrf_filter when browserless is unavailable' do + allow(Html2rss::RequestService).to receive(:strategy_names).and_return([:ssrf_filter]) + + described_class.call! + + expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:ssrf_filter).once + end end end From c06aa815d0c5eac3ead92e4d8823eb45a186e83f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Thu, 19 Mar 2026 16:31:01 +0100 Subject: [PATCH 02/16] feat: streamline first-run onboarding with included feeds --- README.md | 30 +++++++++++++ app/web/api/v1/root_metadata.rb | 21 ++++++++- app/web/config/local_config.rb | 39 ++++++++++++++--- docker-compose.yml | 13 +++--- frontend/src/__tests__/App.contract.test.tsx | 1 + frontend/src/__tests__/App.test.tsx | 45 ++++++++++++++++++-- frontend/src/__tests__/mocks/server.ts | 7 +-- frontend/src/api/contracts.ts | 5 +++ frontend/src/components/App.tsx | 4 +- frontend/src/components/AppPanels.tsx | 26 ++++++++++- spec/html2rss/web/api/v1_spec.rb | 33 +++++++++----- spec/html2rss/web/local_config_spec.rb | 13 ++++++ 12 files changed, 205 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9bf7d39b..af301c97 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,33 @@ curl -X POST "https://your-domain.com/api/v1/feeds" \ -d '{"url":"https://example.com","name":"Example Feed"}' ``` +## Trial Run (Docker Pull And Run) + +The published image already includes a sample `config/feeds.yml`, so you can try the app without creating or mounting one first. + +```bash +docker run --rm \ + -p 4000:4000 \ + -e RACK_ENV=production \ + -e HTML2RSS_SECRET_KEY=$(openssl rand -hex 32) \ + html2rss/web +``` + +Then open: + +- `http://localhost:4000/` for the web UI +- `http://localhost:4000/microsoft.com/azure-products.rss` for a built-in Azure updates feed +- `http://localhost:4000/phys.org/weekly.rss` for a built-in science headlines feed +- `http://localhost:4000/softwareleadweekly.com/issues.rss` for a built-in newsletter archive feed + +This trial run is intentionally minimal: + +- it uses the image's bundled config set, including embedded `html2rss-configs` feeds +- automatic feed generation stays disabled by default +- Browserless is not wired in yet + +Move to Docker Compose when you want Browserless, update automation, or extra local feed overrides in `config/feeds.yml`. + ## Deploy (Docker Compose) 1. Generate a key: `openssl rand -hex 32`. @@ -46,6 +73,9 @@ curl -X POST "https://your-domain.com/api/v1/feeds" \ UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. +The default compose file now uses the bundled config set. +If you want to add or override static feeds locally, uncomment the bind mount in [docker-compose.yml](docker-compose.yml) and provide `./config/feeds.yml`. + ## Development (Dev Container) Use the repository's [Dev Container](.devcontainer/README.md) for all local development and tests. diff --git a/app/web/api/v1/root_metadata.rb b/app/web/api/v1/root_metadata.rb index 2d936b5b..dfdd503c 100644 --- a/app/web/api/v1/root_metadata.rb +++ b/app/web/api/v1/root_metadata.rb @@ -7,6 +7,24 @@ module V1 ## # Builds the public metadata payload for the API root endpoint. module RootMetadata + FEATURED_FEEDS = [ + { + path: '/microsoft.com/azure-products.rss', + title: 'Azure product updates', + description: 'Follow Microsoft Azure product announcements from your own instance.' + }, + { + path: '/phys.org/weekly.rss', + title: 'Top science news of the week', + description: 'Try a high-signal feed with stable weekly headlines from the built-in config set.' + }, + { + path: '/softwareleadweekly.com/issues.rss', + title: 'Software Lead Weekly issues', + description: 'Follow a long-running newsletter archive from the embedded config catalog.' + } + ].freeze + class << self # @param router [Roda::RodaRequest] # @return [Hash{Symbol=>Object}] @@ -30,7 +48,8 @@ def instance_payload(_router) feed_creation: { enabled: AutoSource.enabled?, access_token_required: AutoSource.enabled? - } + }, + featured_feeds: FEATURED_FEEDS } end end diff --git a/app/web/config/local_config.rb b/app/web/config/local_config.rb index 4b937d6c..8146ecea 100644 --- a/app/web/config/local_config.rb +++ b/app/web/config/local_config.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true require 'yaml' +begin + require 'html2rss/configs' +rescue LoadError + nil +end module Html2rss module Web @@ -27,10 +32,8 @@ class << self # @return [Hash] def find(name) normalized_name = normalize_name(name) - config = snapshot.feeds.fetch(normalized_name.to_sym) do - raise NotFound, "Did not find local feed config at '#{normalized_name}'" - end - config_hash = deep_dup(config.raw) + config_hash = local_feed_config(normalized_name) || embedded_feed_config(normalized_name) + raise NotFound, "Did not find local feed config at '#{normalized_name}'" unless config_hash apply_global_defaults(config_hash) end @@ -76,6 +79,30 @@ def reload!(reason: 'manual') private + # @param normalized_name [String] + # @return [Hash{Symbol=>Object}, nil] + def local_feed_config(normalized_name) + config = snapshot.feeds[normalized_name.to_sym] + return nil unless config + + deep_dup(config.raw) + end + + # @param normalized_name [String] + # @return [Hash{Symbol=>Object}, nil] + def embedded_feed_config(normalized_name) + return nil unless defined?(Html2rss::Configs) + return nil unless normalized_name.include?('/') + + deep_dup(Html2rss::Configs.find_by_name(normalized_name)) + rescue Html2rss::Configs::ConfigNotFound + nil + rescue RuntimeError => e + return nil if e.message == 'name must be in folder/file format' + + raise + end + # Applies global defaults only when feed-level keys are absent. # # @param config [Hash{Symbol=>Object}] @@ -90,9 +117,9 @@ def apply_global_defaults(config) end # @param name [String, Symbol, #to_s] - # @return [String] basename without extension for feed lookup. + # @return [String] path without feed extension for feed lookup. def normalize_name(name) - File.basename(name.to_s).sub(FEED_EXTENSION_PATTERN, '') + name.to_s.delete_prefix('/').sub(FEED_EXTENSION_PATTERN, '') end # Deep-duplicates nested config structures to avoid mutating shared data. diff --git a/docker-compose.yml b/docker-compose.yml index c901f2c8..65ca5d17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,7 @@ services: restart: unless-stopped ports: - "127.0.0.1:4000:4000" - volumes: - - type: bind - source: ./config/feeds.yml - target: /app/config/feeds.yml - read_only: true + env_file: .env environment: RACK_ENV: production PORT: 4000 @@ -19,6 +15,13 @@ services: HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN} BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002 BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN} + # Trial runs use the image's bundled config/feeds.yml. + # Uncomment the block below when you want to replace it with your own file. + # volumes: + # - type: bind + # source: ./config/feeds.yml + # target: /app/config/feeds.yml + # read_only: true watchtower: image: containrrr/watchtower diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 3db1701b..171904a5 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -92,6 +92,7 @@ describe('App contract', () => { enabled: true, access_token_required: true, }, + featured_feeds: [], }, }, }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 3ae65760..1eedd47d 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -61,6 +61,7 @@ describe('App', () => { enabled: true, access_token_required: true, }, + featured_feeds: [], }, }, isLoading: false, @@ -78,7 +79,7 @@ describe('App', () => { mockUseStrategies.mockReturnValue({ strategies: [ - { id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard rendering' }, + { id: 'faraday', name: 'faraday', display_name: 'Standard rendering' }, { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, ], isLoading: false, @@ -113,7 +114,7 @@ describe('App', () => { it('falls back to the first available strategy when browserless is unavailable', () => { mockUseStrategies.mockReturnValue({ - strategies: [{ id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard rendering' }], + strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Standard rendering' }], isLoading: false, error: null, }); @@ -121,7 +122,7 @@ describe('App', () => { render(); return waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('ssrf_filter'); + expect(screen.getByRole('combobox')).toHaveValue('faraday'); }); }); @@ -168,6 +169,42 @@ describe('App', () => { expect(mockConvertFeed).not.toHaveBeenCalled(); }); + it('promotes included feeds when feed creation is disabled', () => { + mockUseApiMetadata.mockReturnValue({ + metadata: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: false, + access_token_required: false, + }, + featured_feeds: [ + { + path: '/microsoft.com/azure-products.rss', + title: 'Azure product updates', + description: 'Follow Microsoft Azure product announcements from your own instance.', + }, + ], + }, + }, + isLoading: false, + error: null, + }); + + render(); + + expect(screen.getByText('Try a working included feed')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( + 'href', + '/microsoft.com/azure-products.rss' + ); + expect(screen.getByText('Custom feed generation is disabled for this instance.')).toBeInTheDocument(); + }); + it('renders the result panel when a feed is available', async () => { mockUseFeedConversion.mockReturnValue({ isConverting: false, @@ -175,7 +212,7 @@ describe('App', () => { id: 'feed-123', name: 'Example Feed', url: 'https://example.com/articles', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'example-token', public_url: '/api/v1/feeds/example-token', json_public_url: '/api/v1/feeds/example-token.json', diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 3affee1c..1750262e 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -16,6 +16,7 @@ export const server = setupServer( enabled: true, access_token_required: true, }, + featured_feeds: [], }, }, }); @@ -26,8 +27,8 @@ export const server = setupServer( data: { strategies: [ { - id: 'ssrf_filter', - name: 'ssrf_filter', + id: 'faraday', + name: 'faraday', display_name: 'Standard rendering', }, { @@ -64,7 +65,7 @@ export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { id: overrides.id ?? 'feed-123', name: overrides.name ?? 'Example Feed', url: overrides.url ?? 'https://example.com/articles', - strategy: overrides.strategy ?? 'ssrf_filter', + strategy: overrides.strategy ?? 'faraday', feed_token: overrides.feed_token ?? 'example-token', public_url: overrides.public_url ?? '/api/v1/feeds/example-token', json_public_url: overrides.json_public_url ?? '/api/v1/feeds/example-token.json', diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 57d0a54b..14867b84 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -10,5 +10,10 @@ export interface ApiMetadataRecord { enabled: boolean; access_token_required: boolean; }; + featured_feeds?: Array<{ + path: string; + title: string; + description: string; + }>; }; } diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index c7d1f562..e10b1258 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -74,6 +74,7 @@ export function App() { }, [strategies, feedFormData.strategy]); const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; + const featuredFeeds = metadata?.instance.featured_feeds ?? []; const submitDisabled = isConverting || strategiesLoading || !feedCreation.enabled || showTokenPrompt; const setFeedField = (key: 'url' | 'strategy', value: string) => { @@ -87,7 +88,7 @@ export function App() { }; const strategyHint = (strategy: Strategy) => { - if (strategy.id === 'ssrf_filter') return 'Start here for most pages.'; + if (strategy.id === 'faraday') return 'Start here for most pages.'; if (strategy.id === 'browserless') return 'Use this if the page loads content with JavaScript.'; return strategy.name; }; @@ -234,6 +235,7 @@ export function App() { strategiesLoading={strategiesLoading} strategiesError={strategiesError} feedCreationEnabled={feedCreation.enabled} + featuredFeeds={featuredFeeds} accessTokenRequired={feedCreation.access_token_required} hasAccessToken={hasToken} tokenDraft={tokenDraft} diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 0de35fb0..78109472 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -29,6 +29,7 @@ interface CreateFeedPanelProps { strategiesLoading: boolean; strategiesError: string | null; feedCreationEnabled: boolean; + featuredFeeds: Array<{ path: string; title: string; description: string }>; accessTokenRequired: boolean; hasAccessToken: boolean; tokenDraft: string; @@ -53,6 +54,7 @@ export function CreateFeedPanel({ strategiesLoading, strategiesError, feedCreationEnabled, + featuredFeeds, accessTokenRequired, hasAccessToken, tokenDraft, @@ -69,7 +71,7 @@ export function CreateFeedPanel({ const urlInputRef = useRef(null); const tokenInputRef = useRef(null); const strategyOptionLabel = (strategy: Strategy) => { - if (strategy.id === 'ssrf_filter') return 'Standard rendering'; + if (strategy.id === 'faraday') return 'Standard rendering'; if (strategy.id === 'browserless') return 'JavaScript pages (recommended)'; return strategy.display_name; }; @@ -142,7 +144,27 @@ export function CreateFeedPanel({ )} {!feedCreationEnabled && ( -

Custom feed generation is disabled for this instance.

+ <> +

Custom feed generation is disabled for this instance.

+ {featuredFeeds.length > 0 && ( +
+
Try a working included feed
+

Start with one of the embedded configs from this instance:

+ {featuredFeeds.map((feed) => ( +

+ {feed.title} + {' - '} + {feed.description} +

+ ))} +

+ + Learn how included configs work. + +

+
+ )} + )} diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index f7137b9f..371dfc6a 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -49,14 +49,14 @@ def ghost_feed_token .create_with_validation( username: 'ghost', url: feed_url, - strategy: 'ssrf_filter', + strategy: 'faraday', secret_key: ENV.fetch('HTML2RSS_SECRET_KEY') ) .encode end def valid_feed_token - Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') end def json_feed_response_for(token) @@ -128,6 +128,19 @@ def json_feed_headers_tuple ) end + it 'returns featured included feeds for trial runs', :aggregate_failures do + get '/api/v1' + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'instance', 'featured_feeds')).to include( + include( + 'path' => '/microsoft.com/azure-products.rss', + 'title' => 'Azure product updates' + ) + ) + end + it 'returns API information with trailing slash', :aggregate_failures do get '/api/v1/' @@ -285,7 +298,7 @@ def json_feed_headers_tuple end it 'renders feed for a valid token', :aggregate_failures do - token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') @@ -305,7 +318,7 @@ def json_feed_headers_tuple end it 'prefers xml when Accept quality outranks json', :aggregate_failures do - token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') @@ -317,7 +330,7 @@ def json_feed_headers_tuple end it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do - token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') @@ -346,7 +359,7 @@ def json_feed_headers_tuple it 'returns forbidden when auto source is disabled', :aggregate_failures do unique_url = "#{feed_url}/disabled" - token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') ClimateControl.modify(AUTO_SOURCE_ENABLED: 'false') do get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/xml' } @@ -359,7 +372,7 @@ def json_feed_headers_tuple it 'returns JSON Feed-shaped forbidden errors when requested through Accept', :aggregate_failures do unique_url = "#{feed_url}/disabled-json" - token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') ClimateControl.modify(AUTO_SOURCE_ENABLED: 'false') do get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } @@ -372,7 +385,7 @@ def json_feed_headers_tuple it 'returns non-cacheable xml feed errors when service generation fails', :aggregate_failures do unique_url = "#{feed_url}/service-error-xml" - token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(service_error_result) @@ -386,7 +399,7 @@ def json_feed_headers_tuple it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do unique_url = "#{feed_url}/service-error-json" - token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') status, content_type, cache_control, title = json_feed_service_error_tuple(token) @@ -404,7 +417,7 @@ def json_feed_headers_tuple let(:request_params) do { url: feed_url, - strategy: 'ssrf_filter' + strategy: 'faraday' } end diff --git a/spec/html2rss/web/local_config_spec.rb b/spec/html2rss/web/local_config_spec.rb index 074ffb28..76b0a4c8 100644 --- a/spec/html2rss/web/local_config_spec.rb +++ b/spec/html2rss/web/local_config_spec.rb @@ -21,6 +21,19 @@ def titles_for(*names) expect(titles_for('example.json', 'example.rss', 'example.xml')).to eq(%w[Example Example Example]) end + + it 'falls back to embedded configs when the feed is not in local yaml' do + stub_const('Html2rss::Configs', Module.new) + stub_const('Html2rss::Configs::ConfigNotFound', Class.new(StandardError)) + allow(Html2rss::Configs).to receive(:find_by_name).with('support.apple.com/en_gb_ht201222') + .and_return({ channel: { title: 'Apple security releases' } }) + allow(described_class).to receive(:snapshot) + .and_return(Html2rss::Web::ConfigSnapshot::Snapshot.new(global: {}, feeds: {}, accounts: [])) + + config = described_class.find('support.apple.com/en_gb_ht201222.rss') + + expect(config).to include(channel: { title: 'Apple security releases' }) + end end describe '.snapshot' do From 49074ef6d002287ee52152b089fc807737f62685 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Thu, 19 Mar 2026 16:31:13 +0100 Subject: [PATCH 03/16] refactor: use html2rss default request strategies --- Gemfile.lock | 20 +++++++-------- app/web/api/v1/strategies.rb | 2 +- app/web/boot/setup.rb | 12 +-------- app/web/domain/auto_source.rb | 2 +- app/web/feeds/source_resolver.rb | 4 ++- app/web/security/ssrf_filter_strategy.rb | 25 ------------------- frontend/src/__tests__/ResultDisplay.test.tsx | 2 +- .../useFeedConversion.contract.test.ts | 8 +++--- .../src/__tests__/useFeedConversion.test.ts | 8 +++--- .../html2rss/web/api/v1/feed_metadata_spec.rb | 4 +-- spec/html2rss/web/app_integration_spec.rb | 8 +++--- spec/html2rss/web/boot/setup_spec.rb | 18 +------------ spec/html2rss/web/feeds/cache_spec.rb | 2 +- spec/html2rss/web/feeds/json_renderer_spec.rb | 4 +-- spec/html2rss/web/feeds/responder_spec.rb | 4 +-- spec/html2rss/web/feeds/rss_renderer_spec.rb | 4 +-- spec/html2rss/web/feeds/service_spec.rb | 4 +-- .../web/feeds/source_resolver_spec.rb | 25 +++++++++++++++---- spec/smoke/docker_spec.rb | 6 ++--- 19 files changed, 64 insertions(+), 98 deletions(-) delete mode 100644 app/web/security/ssrf_filter_strategy.rb diff --git a/Gemfile.lock b/Gemfile.lock index 7878935d..5bab8401 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/html2rss/html2rss - revision: e0dca5bf74b17c1e2a0618fc0a4af27c16da1883 + revision: 7672db3109769b059110d8b7bea55cf68ba36a39 branch: master specs: html2rss (0.17.0) @@ -65,7 +65,7 @@ GEM addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) - async (2.38.0) + async (2.38.1) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -167,7 +167,7 @@ GEM io-endpoint (0.17.2) io-event (1.14.4) io-stream (0.11.1) - json (2.19.1) + json (2.19.2) json-schema (6.2.0) addressable (~> 2.8) bigdecimal (>= 3.1, < 5) @@ -185,7 +185,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0303) + mime-types-data (3.2026.0317) minitest (6.0.2) drb (~> 2.0) prism (~> 1.5) @@ -220,7 +220,7 @@ GEM protocol-http2 (0.24.0) protocol-hpack (~> 1.4) protocol-http (~> 0.47) - protocol-rack (0.21.1) + protocol-rack (0.22.0) io-stream (>= 0.10) protocol-http (~> 0.58) rack (>= 1.0) @@ -399,7 +399,7 @@ CHECKSUMS activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - async (2.38.0) sha256=f95d00da2eb72e2c5340a6d78c321ec70cec65cbeceb0dc2cb2a32ff17a0f4cf + async (2.38.1) sha256=72ba6b7de04d852355458bfe891221226bb7d29f055f5cb043ae3345497f8cec async-http (0.94.2) sha256=c5ca94b337976578904a373833abe5b8dfb466a2946af75c4ae38c409c5c78b2 async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed async-websocket (0.30.0) sha256=55739954528ad8f87f7792d0452e1268d1ef2aa5b3719f79400a05a1a6202cdf @@ -441,7 +441,7 @@ CHECKSUMS io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338 io-event (1.14.4) sha256=455a9e4fb4613d12867b90461c297af6993b400a521bf62046f83b27f9c6aa3d io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87 - json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d + json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc @@ -451,7 +451,7 @@ CHECKSUMS mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224 + mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 @@ -470,7 +470,7 @@ CHECKSUMS protocol-http (0.60.0) sha256=ca1354947676d663b6f23c49654aee464288774e7867c4a6e406fecce9691cec protocol-http1 (0.37.0) sha256=5bdd739e28792b341134596f6f5ab21a9d4b395f67bae69e153743eb0e69d123 protocol-http2 (0.24.0) sha256=65327a019b7e36d2774e94050bf57a43bb60212775d2fcf02ae1d2ed4f01ef28 - protocol-rack (0.21.1) sha256=366ff16efbf4c2f8d2e3fad4e992effa2357610f70effbccfa2767d26fedc577 + protocol-rack (0.22.0) sha256=b7c49c0b597ca2c6d20f8bcd746c4415a1b750eacfbe64f828e780c978a4293d protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1 protocol-websocket (0.20.2) sha256=c41d93c35fba5dae85375c597f76975f3dbd75d8c5b2f21b33dab4dc22a5a511 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 @@ -530,4 +530,4 @@ CHECKSUMS zlib (3.2.3) sha256=5bd316698b32f31a64ab910a8b6c282442ca1626a81bbd6a1674e8522e319c20 BUNDLED WITH - 4.0.8 + 4.0.6 diff --git a/app/web/api/v1/strategies.rb b/app/web/api/v1/strategies.rb index 91822fd6..992211b7 100644 --- a/app/web/api/v1/strategies.rb +++ b/app/web/api/v1/strategies.rb @@ -33,7 +33,7 @@ def index(_request) def display_name_for(name) case name.to_s - when 'ssrf_filter' then 'Standard rendering' + when 'faraday' then 'Standard rendering' when 'browserless' then 'JavaScript pages (recommended)' else name.to_s.split('_').map(&:capitalize).join(' ') end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index f5657160..fd69faa8 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -26,17 +26,7 @@ def validate_environment! # @return [void] def configure_request_service! - Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) - Html2rss::RequestService.default_strategy_name = preferred_default_strategy - Html2rss::RequestService.unregister_strategy(:faraday) - end - - # @return [Symbol] - def preferred_default_strategy - supported = Html2rss::RequestService.strategy_names.map(&:to_sym) - return :browserless if supported.include?(:browserless) - - :ssrf_filter + nil end end end diff --git a/app/web/domain/auto_source.rb b/app/web/domain/auto_source.rb index 4b1d09c7..becd389e 100644 --- a/app/web/domain/auto_source.rb +++ b/app/web/domain/auto_source.rb @@ -21,7 +21,7 @@ def enabled? # @param token_data [Hash{Symbol=>Object}] authenticated account data. # @param strategy [String] # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata, nil] - def create_stable_feed(name, url, token_data, strategy = 'ssrf_filter') + def create_stable_feed(name, url, token_data, strategy = 'faraday') return nil unless token_data && FeedAccess.url_allowed_for_username?(token_data[:username], url) feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy) diff --git a/app/web/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb index 0bb33a6e..031e0505 100644 --- a/app/web/feeds/source_resolver.rb +++ b/app/web/feeds/source_resolver.rb @@ -36,6 +36,8 @@ def resolve_static(feed_request) generator_input: generator_input, ttl_seconds: CacheTtl.seconds_from_minutes(generator_input.dig(:channel, :ttl)) ) + rescue LocalConfig::NotFound + raise Html2rss::Web::NotFoundError, "Feed '#{feed_request.feed_name}' is not available on this instance" end # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] @@ -69,7 +71,7 @@ def static_cache_identity(feed_name, params) def static_generator_input(config, params) generator_input = config.dup generator_input[:params] = merged_static_params(config, params) - generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name + generator_input[:strategy] ||= :faraday generator_input end diff --git a/app/web/security/ssrf_filter_strategy.rb b/app/web/security/ssrf_filter_strategy.rb deleted file mode 100644 index 2ad3f76f..00000000 --- a/app/web/security/ssrf_filter_strategy.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'ssrf_filter' -require 'html2rss' -module Html2rss - module Web - ## - # Strategy to fetch a URL using the SSRF filter. - class SsrfFilterStrategy < Html2rss::RequestService::Strategy - # Executes a URL fetch through `ssrf_filter` and adapts response shape. - # - # @return [Html2rss::RequestService::Response] - def execute - headers = LocalConfig.global.fetch(:headers, {}).merge( - ctx.headers.transform_keys(&:to_sym) - ) - response = SsrfFilter.get(ctx.url, headers:) - - Html2rss::RequestService::Response.new(body: response.body, - url: ctx.url, - headers: response.to_hash.transform_values(&:first)) - end - end - end -end diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index c9ad54a5..f2f6fddf 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -9,7 +9,7 @@ describe('ResultDisplay', () => { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'test-feed-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 3c46604d..a7f51a17 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -13,7 +13,7 @@ describe('useFeedConversion contract', () => { const body = (await request.json()) as { url: string; strategy: string }; receivedAuthorization = request.headers.get('authorization'); - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' }); + expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); return HttpResponse.json( buildFeedResponse({ @@ -30,7 +30,7 @@ describe('useFeedConversion contract', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'test-token-123'); + await result.current.convertFeed('https://example.com/articles', 'faraday', 'test-token-123'); }); expect(receivedAuthorization).toBe('Bearer test-token-123'); @@ -54,7 +54,7 @@ describe('useFeedConversion contract', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + result.current.convertFeed('https://example.com/articles', 'faraday', 'token') ).rejects.toThrow('URL parameter is required'); }); @@ -76,7 +76,7 @@ describe('useFeedConversion contract', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + result.current.convertFeed('https://example.com/articles', 'faraday', 'token') ).rejects.toThrow('Invalid response format from feed creation API'); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index c2583985..393ae572 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -27,7 +27,7 @@ describe('useFeedConversion', () => { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'test-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', @@ -51,7 +51,7 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken'); + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); }); expect(result.current.isConverting).toBe(false); @@ -78,7 +78,7 @@ describe('useFeedConversion', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') + result.current.convertFeed('https://example.com', 'faraday', 'testtoken') ).rejects.toThrow('Bad Request'); }); @@ -94,7 +94,7 @@ describe('useFeedConversion', () => { await act(async () => { await expect( - result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') + result.current.convertFeed('https://example.com', 'faraday', 'testtoken') ).rejects.toThrow('Network error'); }); diff --git a/spec/html2rss/web/api/v1/feed_metadata_spec.rb b/spec/html2rss/web/api/v1/feed_metadata_spec.rb index dc8780b4..fb9e55fa 100644 --- a/spec/html2rss/web/api/v1/feed_metadata_spec.rb +++ b/spec/html2rss/web/api/v1/feed_metadata_spec.rb @@ -11,7 +11,7 @@ name: 'Example Feed', url: 'https://example.com/articles', username: 'alice', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'generated-token', identity_token: 'account-token' } @@ -23,7 +23,7 @@ name: 'Example Feed', url: 'https://example.com/articles', username: 'alice', - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json' diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index e84b8b20..8cb2a230 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -59,7 +59,7 @@ Html2rss::Web::FeedToken, url: feed_url, username: account[:username], - strategy: 'ssrf_filter' + strategy: 'faraday' ) allow(Html2rss::Web::FeedToken).to receive_messages( decode: token_payload, @@ -187,7 +187,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) Html2rss::Web::FeedToken, url: feed_url, username: account[:username], - strategy: 'ssrf_filter' + strategy: 'faraday' ) allow(Html2rss::Web::FeedToken).to receive(:decode).with(raw_token).and_return(escaped_token_payload) @@ -203,7 +203,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) let(:request_payload) do { url: feed_url, - strategy: 'ssrf_filter' + strategy: 'faraday' } end @@ -212,7 +212,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) id: 'feed-123', name: 'Example Feed', url: feed_url, - strategy: 'ssrf_filter', + strategy: 'faraday', feed_token: feed_token, public_url: "/api/v1/feeds/#{feed_token}", json_public_url: "/api/v1/feeds/#{feed_token}.json", diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index 5505289d..8682dbce 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -10,30 +10,14 @@ allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) allow(Html2rss::Web::Flags).to receive(:validate!) - allow(Html2rss::RequestService).to receive(:register_strategy) - allow(Html2rss::RequestService).to receive(:strategy_names).and_return(%i[ssrf_filter browserless]) - allow(Html2rss::RequestService).to receive(:default_strategy_name=) - allow(Html2rss::RequestService).to receive(:unregister_strategy) end - it 'validates environment state and configures the request service', :aggregate_failures do + it 'validates environment state', :aggregate_failures do described_class.call! expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_environment!).once expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_production_security!).once expect(Html2rss::Web::Flags).to have_received(:validate!).once - expect(Html2rss::RequestService).to have_received(:register_strategy) - .with(:ssrf_filter, Html2rss::Web::SsrfFilterStrategy).once - expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:browserless).once - expect(Html2rss::RequestService).to have_received(:unregister_strategy).with(:faraday).once - end - - it 'falls back to ssrf_filter when browserless is unavailable' do - allow(Html2rss::RequestService).to receive(:strategy_names).and_return([:ssrf_filter]) - - described_class.call! - - expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:ssrf_filter).once end end end diff --git a/spec/html2rss/web/feeds/cache_spec.rb b/spec/html2rss/web/feeds/cache_spec.rb index eb2bacf5..0bcb2b36 100644 --- a/spec/html2rss/web/feeds/cache_spec.rb +++ b/spec/html2rss/web/feeds/cache_spec.rb @@ -13,7 +13,7 @@ feed: Object.new, site_title: 'Example', url: 'https://example.com', - strategy: 'ssrf_filter' + strategy: 'faraday' ), message: nil, ttl_seconds: 60, diff --git a/spec/html2rss/web/feeds/json_renderer_spec.rb b/spec/html2rss/web/feeds/json_renderer_spec.rb index a3a59e3a..d14fef9c 100644 --- a/spec/html2rss/web/feeds/json_renderer_spec.rb +++ b/spec/html2rss/web/feeds/json_renderer_spec.rb @@ -11,7 +11,7 @@ feed: Object.new, site_title: 'https://example.com/articles', url: 'https://example.com/articles', - strategy: 'ssrf_filter' + strategy: 'faraday' ) end let(:empty_result) do @@ -37,7 +37,7 @@ def expected_builder_args { url: 'https://example.com/articles', - strategy: 'ssrf_filter', + strategy: 'faraday', site_title: 'https://example.com/articles' } end diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index 2dd1d2e9..d2e51307 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -21,7 +21,7 @@ def resolved_source Html2rss::Web::Feeds::Contracts::ResolvedSource.new( source_kind: :token, cache_identity: 'token:abc', - generator_input: { strategy: :ssrf_filter, channel: { url: 'https://example.com' } }, + generator_input: { strategy: :faraday, channel: { url: 'https://example.com' } }, ttl_seconds: 600 ) end @@ -74,7 +74,7 @@ def resolved_source expect(Html2rss::Web::Observability).to have_received(:emit).with( event_name: 'feed.render', outcome: 'success', - details: include(strategy: :ssrf_filter, url: 'https://example.com'), + details: include(strategy: :faraday, url: 'https://example.com'), level: :info ) end diff --git a/spec/html2rss/web/feeds/rss_renderer_spec.rb b/spec/html2rss/web/feeds/rss_renderer_spec.rb index a4e7f2f9..ca07c26a 100644 --- a/spec/html2rss/web/feeds/rss_renderer_spec.rb +++ b/spec/html2rss/web/feeds/rss_renderer_spec.rb @@ -11,7 +11,7 @@ feed: Object.new, site_title: 'https://example.com/articles', url: 'https://example.com/articles', - strategy: 'ssrf_filter' + strategy: 'faraday' ) end let(:empty_result) do @@ -37,7 +37,7 @@ def expected_builder_args { url: 'https://example.com/articles', - strategy: 'ssrf_filter', + strategy: 'faraday', site_title: 'https://example.com/articles' } end diff --git a/spec/html2rss/web/feeds/service_spec.rb b/spec/html2rss/web/feeds/service_spec.rb index 834f3cc6..4f085ca4 100644 --- a/spec/html2rss/web/feeds/service_spec.rb +++ b/spec/html2rss/web/feeds/service_spec.rb @@ -11,7 +11,7 @@ source_kind: :static, cache_identity: 'example-feed:abc123', generator_input: { - strategy: :ssrf_filter, + strategy: :faraday, channel: { url: 'https://example.com/articles' }, auto_source: {} }, @@ -109,7 +109,7 @@ def expected_payload feed: feed, site_title: 'Example Feed', url: 'https://example.com/articles', - strategy: 'ssrf_filter' + strategy: 'faraday' ) end end diff --git a/spec/html2rss/web/feeds/source_resolver_spec.rb b/spec/html2rss/web/feeds/source_resolver_spec.rb index 42e431a2..b41dff74 100644 --- a/spec/html2rss/web/feeds/source_resolver_spec.rb +++ b/spec/html2rss/web/feeds/source_resolver_spec.rb @@ -29,7 +29,6 @@ def resolved_tuple(resolved) before do allow(Html2rss::Web::LocalConfig).to receive(:find).with('legacy').and_return(config) - allow(Html2rss::RequestService).to receive(:default_strategy_name).and_return(:ssrf_filter) end it 'normalizes the static source into shared generator input', :aggregate_failures do @@ -40,7 +39,7 @@ def resolved_tuple(resolved) :static, start_with('static:legacy:'), 900, - include(params: { 'existing' => '1', 'page' => '3' }, strategy: :ssrf_filter) + include(params: { 'existing' => '1', 'page' => '3' }, strategy: :faraday) ] ) end @@ -53,6 +52,22 @@ def resolved_tuple(resolved) params: { 'existing' => '1' } ) end + + it 'preserves an explicit static strategy when configured' do + config[:strategy] = :browserless + + resolved = described_class.call(feed_request) + + expect(resolved.generator_input[:strategy]).to eq(:browserless) + end + + it 'returns a not-found error when the static feed is unavailable' do + allow(Html2rss::Web::LocalConfig).to receive(:find).with('legacy') + .and_raise(Html2rss::Web::LocalConfig::NotFound, 'missing') + + expect { described_class.call(feed_request) } + .to raise_error(Html2rss::Web::NotFoundError, "Feed 'legacy' is not available on this instance") + end end context 'with a token request' do @@ -70,7 +85,7 @@ def resolved_tuple(resolved) Html2rss::Web::FeedToken, username: 'admin', url: 'https://example.com/private', - strategy: 'ssrf_filter' + strategy: 'faraday' ) end @@ -82,7 +97,7 @@ def resolved_tuple(resolved) allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?) .with({ username: 'admin' }, 'https://example.com/private').and_return(true) allow(Html2rss::Web::AutoSource).to receive(:enabled?).and_return(true) - allow(Html2rss::RequestService).to receive(:strategy_names).and_return([:ssrf_filter]) + allow(Html2rss::RequestService).to receive(:strategy_names).and_return([:faraday]) allow(Html2rss::Web::LocalConfig).to receive(:global) .and_return({ headers: { 'User-Agent' => 'html2rss-web' } }) end @@ -92,7 +107,7 @@ def resolved_tuple(resolved) expect(resolved_tuple(resolved)).to match( [:token, start_with('token:'), 300, - include(strategy: :ssrf_filter, channel: { url: 'https://example.com/private' }, auto_source: {})] + include(strategy: :faraday, channel: { url: 'https://example.com/private' }, auto_source: {})] ) end end diff --git a/spec/smoke/docker_spec.rb b/spec/smoke/docker_spec.rb index a4ba31cd..115d2b76 100644 --- a/spec/smoke/docker_spec.rb +++ b/spec/smoke/docker_spec.rb @@ -71,7 +71,7 @@ def expect_json_feed_response(path) it 'creates a feed when provided with valid credentials', :aggregate_failures do payload = { url: feed_url, - strategy: 'ssrf_filter' + strategy: 'faraday' } response, body = post_json('/api/v1/feeds', body: payload) @@ -84,7 +84,7 @@ def expect_json_feed_response(path) payload = { url: feed_url, - strategy: 'ssrf_filter' + strategy: 'faraday' } response, body = post_json('/api/v1/feeds', @@ -101,7 +101,7 @@ def expect_json_feed_response(path) payload = { url: feed_url, - strategy: 'ssrf_filter' + strategy: 'faraday' } response, body = post_json('/api/v1/feeds', From 2a14aaeede7bd5d49a3de1ba19f932c00aaa65fd Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Thu, 19 Mar 2026 16:44:15 +0100 Subject: [PATCH 04/16] Remove ssrf_filter dependency --- Gemfile | 1 - Gemfile.lock | 3 --- README.md | 2 +- app/web/config/local_config.rb | 4 ++-- docs/README.md | 2 +- frontend/src/__tests__/useFeedConversion.test.ts | 12 ++++++------ frontend/src/components/AppPanels.tsx | 6 +++++- spec/html2rss/web/app_spec.rb | 13 ++++++++++--- spec/html2rss/web/feeds/source_resolver_spec.rb | 4 +++- spec/html2rss/web/local_config_spec.rb | 8 ++++++-- 10 files changed, 34 insertions(+), 21 deletions(-) diff --git a/Gemfile b/Gemfile index 8f9ae8c3..ac313275 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,6 @@ gem 'parallel' gem 'rack-cache' gem 'rack-timeout' gem 'roda' -gem 'ssrf_filter' gem 'zeitwerk' gem 'puma', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5bab8401..6eac5921 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -333,7 +333,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - ssrf_filter (1.3.0) stackprof (0.2.28) thor (1.5.0) traces (0.18.2) @@ -386,7 +385,6 @@ DEPENDENCIES ruby-lsp sentry-ruby simplecov - ssrf_filter stackprof vcr webmock @@ -513,7 +511,6 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 - ssrf_filter (1.3.0) sha256=66882d7de7d09c019098d6d7372412950ae184ebbc7c51478002058307aba6f2 stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 diff --git a/README.md b/README.md index af301c97..f6a8f23f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ html2rss-web converts arbitrary websites into RSS 2.0 feeds with a slim Ruby bac - Responsive Preact interface for demo, sign-in, conversion, and result flows. - Automatic source discovery with token-scoped permissions. - Signed public feed URLs that work in standard RSS readers. -- Built-in SSRF defences, input validation, and HMAC-protected tokens. +- Built-in URL validation, scoped feed access controls, and HMAC-protected tokens. ## Architecture diff --git a/app/web/config/local_config.rb b/app/web/config/local_config.rb index 8146ecea..fb3df7c8 100644 --- a/app/web/config/local_config.rb +++ b/app/web/config/local_config.rb @@ -97,8 +97,8 @@ def embedded_feed_config(normalized_name) deep_dup(Html2rss::Configs.find_by_name(normalized_name)) rescue Html2rss::Configs::ConfigNotFound nil - rescue RuntimeError => e - return nil if e.message == 'name must be in folder/file format' + rescue RuntimeError => error + return nil if error.message == 'name must be in folder/file format' raise end diff --git a/docs/README.md b/docs/README.md index c98c8bae..5bbabf69 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,7 +48,7 @@ Frontend verification lives at `http://127.0.0.1:4001/` while the dev container - Keep route composition in `app/web/routes/**`. - Keep `/api/v1` contract-specific code in `app/web/api/**`. - Keep feed fetching, caching, and orchestration in `app/web/feeds/**`. -- Keep auth, token handling, SSRF strategy, and security logging in `app/web/security/**`. +- Keep auth, token handling, URL validation, and security logging in `app/web/security/**`. - Keep request-scoped context in `app/web/request/**`. - Keep boot/runtime setup in `app/web/boot/**`. - Do not create generic buckets such as `services`, `helpers`, `utils`, or `concerns`. diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 393ae572..9fe72176 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -77,9 +77,9 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com', 'faraday', 'testtoken') - ).rejects.toThrow('Bad Request'); + await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( + 'Bad Request' + ); }); expect(result.current.isConverting).toBe(false); @@ -93,9 +93,9 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com', 'faraday', 'testtoken') - ).rejects.toThrow('Network error'); + await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( + 'Network error' + ); }); expect(result.current.isConverting).toBe(false); diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 78109472..0e0a0aee 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -158,7 +158,11 @@ export function CreateFeedPanel({

))}

- + Learn how included configs work.

diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index d387fa86..eb85f9c0 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -160,7 +160,7 @@ def app = described_class get '/missing-feed' - expect(last_response.status).to eq(500) + expect(last_response.status).to eq(404) expect(last_response.headers['Content-Type']).to eq('application/xml') expect(last_response.body).to eq('') end @@ -169,8 +169,15 @@ def app = described_class get '/missing-feed.json' expect(json_feed_error_tuple).to eq( - [500, 'application/feed+json', { 'version' => 'https://jsonfeed.org/version/1.1', 'title' => 'Error', - 'description' => 'Failed to generate feed: Internal Server Error' }] + [ + 404, + 'application/feed+json', + { + 'version' => 'https://jsonfeed.org/version/1.1', + 'title' => 'Error', + 'description' => "Failed to generate feed: Feed 'missing-feed' is not available on this instance" + } + ] ) end diff --git a/spec/html2rss/web/feeds/source_resolver_spec.rb b/spec/html2rss/web/feeds/source_resolver_spec.rb index b41dff74..bc3d1aa0 100644 --- a/spec/html2rss/web/feeds/source_resolver_spec.rb +++ b/spec/html2rss/web/feeds/source_resolver_spec.rb @@ -62,7 +62,9 @@ def resolved_tuple(resolved) end it 'returns a not-found error when the static feed is unavailable' do - allow(Html2rss::Web::LocalConfig).to receive(:find).with('legacy') + allow(Html2rss::Web::LocalConfig) + .to receive(:find) + .with('legacy') .and_raise(Html2rss::Web::LocalConfig::NotFound, 'missing') expect { described_class.call(feed_request) } diff --git a/spec/html2rss/web/local_config_spec.rb b/spec/html2rss/web/local_config_spec.rb index 76b0a4c8..141e8a97 100644 --- a/spec/html2rss/web/local_config_spec.rb +++ b/spec/html2rss/web/local_config_spec.rb @@ -23,9 +23,13 @@ def titles_for(*names) end it 'falls back to embedded configs when the feed is not in local yaml' do - stub_const('Html2rss::Configs', Module.new) + stub_const('Html2rss::Configs', Module.new do + def self.find_by_name(_name); end + end) stub_const('Html2rss::Configs::ConfigNotFound', Class.new(StandardError)) - allow(Html2rss::Configs).to receive(:find_by_name).with('support.apple.com/en_gb_ht201222') + allow(Html2rss::Configs) + .to receive(:find_by_name) + .with('support.apple.com/en_gb_ht201222') .and_return({ channel: { title: 'Apple security releases' } }) allow(described_class).to receive(:snapshot) .and_return(Html2rss::Web::ConfigSnapshot::Snapshot.new(global: {}, feeds: {}, accounts: [])) From 6772e7f8ccc2ee5313cb6abeecd218d401ef1e96 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Thu, 19 Mar 2026 22:39:22 +0100 Subject: [PATCH 05/16] Strengthen XML error feed spec --- spec/html2rss/web/app_spec.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index eb85f9c0..9021ca90 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -156,13 +156,14 @@ def app = described_class end it 'renders XML error when static feed generation fails' do - allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') - get '/missing-feed' + xml = Nokogiri::XML(last_response.body) expect(last_response.status).to eq(404) expect(last_response.headers['Content-Type']).to eq('application/xml') - expect(last_response.body).to eq('') + expect(xml.at_xpath('/rss/channel/title')&.text).to eq('Error') + expect(xml.at_xpath('/rss/channel/description')&.text) + .to eq("Failed to generate feed: Feed 'missing-feed' is not available on this instance") end it 'renders JSON Feed-shaped errors when static json feed generation fails' do From 07cc691e7fa0a19528689055f72dfb43a6c679dd Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Thu, 19 Mar 2026 22:46:31 +0100 Subject: [PATCH 06/16] Simplify strategy labeling --- app/web/api/v1/strategies.rb | 2 +- app/web/boot/setup.rb | 8 +------- frontend/src/__tests__/App.test.tsx | 4 ++-- frontend/src/__tests__/mocks/server.ts | 2 +- frontend/src/components/AppPanels.tsx | 2 +- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/web/api/v1/strategies.rb b/app/web/api/v1/strategies.rb index 992211b7..d34f409d 100644 --- a/app/web/api/v1/strategies.rb +++ b/app/web/api/v1/strategies.rb @@ -33,7 +33,7 @@ def index(_request) def display_name_for(name) case name.to_s - when 'faraday' then 'Standard rendering' + when 'faraday' then 'Default' when 'browserless' then 'JavaScript pages (recommended)' else name.to_s.split('_').map(&:capitalize).join(' ') end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index fd69faa8..7d5344f0 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -7,12 +7,11 @@ module Boot # Applies boot-time runtime configuration outside the Roda class body. module Setup class << self - # Validates environment configuration and wires the request service. + # Validates environment configuration. # # @return [void] def call! validate_environment! - configure_request_service! end private @@ -23,11 +22,6 @@ def validate_environment! EnvironmentValidator.validate_production_security! Flags.validate! end - - # @return [void] - def configure_request_service! - nil - end end end end diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 1eedd47d..dfa85f76 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -79,7 +79,7 @@ describe('App', () => { mockUseStrategies.mockReturnValue({ strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Standard rendering' }, + { id: 'faraday', name: 'faraday', display_name: 'Default' }, { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, ], isLoading: false, @@ -114,7 +114,7 @@ describe('App', () => { it('falls back to the first available strategy when browserless is unavailable', () => { mockUseStrategies.mockReturnValue({ - strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Standard rendering' }], + strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }], isLoading: false, error: null, }); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 1750262e..00359bc6 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -29,7 +29,7 @@ export const server = setupServer( { id: 'faraday', name: 'faraday', - display_name: 'Standard rendering', + display_name: 'Default', }, { id: 'browserless', diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 0e0a0aee..c407567c 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -71,7 +71,7 @@ export function CreateFeedPanel({ const urlInputRef = useRef(null); const tokenInputRef = useRef(null); const strategyOptionLabel = (strategy: Strategy) => { - if (strategy.id === 'faraday') return 'Standard rendering'; + if (strategy.id === 'faraday') return 'Default'; if (strategy.id === 'browserless') return 'JavaScript pages (recommended)'; return strategy.display_name; }; From 91fb8d542e01b9103b335fb3f9e6dfe661dbaf4d Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Fri, 20 Mar 2026 09:57:37 +0100 Subject: [PATCH 07/16] Tighten featured feed examples --- README.md | 2 +- spec/html2rss/web/api/v1_spec.rb | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f6a8f23f..f3479540 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ This trial run is intentionally minimal: - automatic feed generation stays disabled by default - Browserless is not wired in yet -Move to Docker Compose when you want Browserless, update automation, or extra local feed overrides in `config/feeds.yml`. +Use Docker Compose for Browserless, auto-updates, or local feed overrides. ## Deploy (Docker Compose) diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 371dfc6a..963f9f50 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -81,6 +81,17 @@ def json_feed_headers_tuple ] end + def expected_featured_feeds + [ + ['/microsoft.com/azure-products.rss', 'Azure product updates', + 'Follow Microsoft Azure product announcements from your own instance.'], + ['/phys.org/weekly.rss', 'Top science news of the week', + 'Try a high-signal feed with stable weekly headlines from the built-in config set.'], + ['/softwareleadweekly.com/issues.rss', 'Software Lead Weekly issues', + 'Follow a long-running newsletter archive from the embedded config catalog.'] + ].map { |path, title, description| { 'path' => path, 'title' => title, 'description' => description } } + end + around do |example| ClimateControl.modify(AUTO_SOURCE_ENABLED: 'true') { example.run } end @@ -133,12 +144,7 @@ def json_feed_headers_tuple expect(last_response.status).to eq(200) json = expect_success_response(last_response) - expect(json.dig('data', 'instance', 'featured_feeds')).to include( - include( - 'path' => '/microsoft.com/azure-products.rss', - 'title' => 'Azure product updates' - ) - ) + expect(json.dig('data', 'instance', 'featured_feeds')).to eq(expected_featured_feeds) end it 'returns API information with trailing slash', :aggregate_failures do From d86d9cbd828fc4e33b4f0cbe7809fdc66b0e4583 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Fri, 20 Mar 2026 17:39:28 +0100 Subject: [PATCH 08/16] Refine feed result and rendered feed UI --- frontend/src/__tests__/App.contract.test.tsx | 15 +- frontend/src/__tests__/App.test.tsx | 5 + frontend/src/__tests__/ResultDisplay.test.tsx | 27 +- frontend/src/components/DominantField.tsx | 4 +- frontend/src/components/ResultDisplay.tsx | 123 +++- frontend/src/styles/main.css | 227 +++++-- public/rss.xsl | 566 ++++++++++++++++-- 7 files changed, 838 insertions(+), 129 deletions(-) diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 171904a5..87d906a6 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -35,7 +35,14 @@ describe('App contract', () => { return HttpResponse.json( { - items: [{ title: 'Contract Item' }], + items: [ + { + title: 'Contract Item', + content_text: 'Contract preview excerpt.', + url: 'https://example.com/contract-item', + date_published: '2024-01-01T00:00:00Z', + }, + ], }, { headers: { 'content-type': 'application/feed+json' }, @@ -57,16 +64,18 @@ describe('App contract', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { + expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'http://localhost:3000/api/v1/feeds/generated-token.json' ); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.getByText('Feed preview')).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); expect(screen.getByText('Contract Item')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index dfa85f76..b87ab015 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -206,6 +206,11 @@ describe('App', () => { }); it('renders the result panel when a feed is available', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ items: [] }), + } as Response); + mockUseFeedConversion.mockReturnValue({ isConverting: false, result: { diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index f2f6fddf..b69fd86c 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -21,28 +21,44 @@ describe('ResultDisplay', () => { ok: true, json: async () => ({ items: [ - { title: 'Item One' }, - { content_text: '56 points by canpan 1 hour ago | hide | 18 comments' }, - { content_text: '2. Item Two ( example.com )' }, + { + title: 'Item One', + content_text: '

First preview item with markup.

', + url: 'https://example.com/item-one', + date_published: '2024-01-01T00:00:00Z', + }, + { + content_text: '56 points by canpan 1 hour ago | hide | 18 comments', + date_published: '2024-01-02T00:00:00Z', + }, + { + content_text: '2. Item Two ( example.com )', + url: 'https://example.com/item-two', + date_published: '2024-01-03T00:00:00Z', + }, ], }), } as Response); }); - it('renders the simplified result actions and preview', async () => { + it('renders the success state actions and richer preview cards', async () => { render(); + expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'https://example.com/feed.json' ); await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); + expect(screen.getByText('First preview item with markup.')).toBeInTheDocument(); + expect(screen.getAllByText('Open original')).toHaveLength(2); expect(screen.getByText(/points by canpan/i)).toBeInTheDocument(); expect(screen.getByText('Item Two')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', { headers: { Accept: 'application/feed+json' }, @@ -59,6 +75,7 @@ describe('ResultDisplay', () => { await waitFor(() => { expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/DominantField.tsx b/frontend/src/components/DominantField.tsx index 75c902f3..c1de6a11 100644 --- a/frontend/src/components/DominantField.tsx +++ b/frontend/src/components/DominantField.tsx @@ -1,6 +1,7 @@ import type { JSX, Ref } from 'preact'; interface DominantFieldProps { + className?: string; id: string; label: string; value: string; @@ -19,6 +20,7 @@ interface DominantFieldProps { } export function DominantField({ + className, id, label, value, @@ -36,7 +38,7 @@ export function DominantField({ error, }: DominantFieldProps) { return ( -
+