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 && (
+
+ )}
+ >
)}
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 (
-
+