Skip to content
Merged
21 changes: 20 additions & 1 deletion app/web/api/v1/root_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
Expand All @@ -30,7 +48,8 @@ def instance_payload(_router)
feed_creation: {
enabled: AutoSource.enabled?,
access_token_required: AutoSource.enabled?
}
},
featured_feeds: FEATURED_FEEDS
}
Comment thread
gildesmarais marked this conversation as resolved.
end
end
Expand Down
36 changes: 30 additions & 6 deletions app/web/config/local_config.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# frozen_string_literal: true

require 'yaml'
begin
require 'html2rss/configs'
rescue LoadError
nil
Comment thread
gildesmarais marked this conversation as resolved.
Outdated
end

module Html2rss
module Web
Expand All @@ -17,6 +22,7 @@ class NotFound < RuntimeError; end
# raised when the local config shape is invalid
class InvalidConfig < RuntimeError; end
FEED_EXTENSION_PATTERN = /\.(json|rss|xml)\z/
EMBEDDED_FEED_NAME_PATTERN = %r{\A[^/]+/.+\z}

# Path to local feed configuration file.
CONFIG_FILE = 'config/feeds.yml'
Expand All @@ -27,10 +33,8 @@ class << self
# @return [Hash<Symbol, Any>]
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
Expand Down Expand Up @@ -76,6 +80,26 @@ 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.match?(EMBEDDED_FEED_NAME_PATTERN)

deep_dup(Html2rss::Configs.find_by_name(normalized_name))
rescue Html2rss::Configs::ConfigNotFound
nil
end

# Applies global defaults only when feed-level keys are absent.
#
# @param config [Hash{Symbol=>Object}]
Expand All @@ -90,9 +114,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.
Expand Down
2 changes: 1 addition & 1 deletion app/web/domain/auto_source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 'faraday')
def create_stable_feed(name, url, token_data, strategy = Html2rss::RequestService.default_strategy_name.to_s)
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)
Expand Down
2 changes: 1 addition & 1 deletion app/web/feeds/source_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,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] ||= :faraday
generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name.to_sym
generator_input
end

Expand Down
15 changes: 10 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ 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:
- path: .env
required: false
environment:
RACK_ENV: production
PORT: 4000
HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY}
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
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/__tests__/App.contract.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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' },
Expand All @@ -47,23 +54,28 @@ describe('App contract', () => {
render(<App />);

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' } });

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();
});
});
Expand All @@ -89,6 +101,7 @@ describe('App contract', () => {
enabled: true,
access_token_required: true,
},
featured_feeds: [],
},
},
});
Expand Down Expand Up @@ -135,6 +148,9 @@ describe('App contract', () => {
render(<App />);

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' },
Expand Down
113 changes: 109 additions & 4 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('App', () => {

beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, '', 'http://localhost:3000/');

mockUseAccessToken.mockReturnValue({
token: null,
Expand All @@ -60,6 +61,7 @@ describe('App', () => {
enabled: true,
access_token_required: true,
},
featured_feeds: [],
},
},
isLoading: false,
Expand All @@ -77,8 +79,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: 'faraday', name: 'faraday', display_name: 'Default' },
{ id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' },
],
isLoading: false,
error: null,
Expand All @@ -102,6 +104,50 @@ describe('App', () => {
});
});

it('prefers browserless as the default strategy when available', () => {
render(<App />);

return waitFor(() => {
expect(screen.getByRole('combobox')).toHaveValue('browserless');
});
});

it('falls back to the first available strategy when browserless is unavailable', () => {
mockUseStrategies.mockReturnValue({
strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }],
isLoading: false,
error: null,
});

render(<App />);

return waitFor(() => {
expect(screen.getByRole('combobox')).toHaveValue('faraday');
});
});

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(<App />);

await waitFor(() => {
expect(mockConvertFeed).toHaveBeenCalledWith(
'https://example.com/articles',
'browserless',
'saved-token'
);
});
});

it('shows inline token prompt when submitting without a token', async () => {
render(<App />);

Expand All @@ -123,14 +169,55 @@ 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(<App />);

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 () => {
vi.spyOn(window, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ items: [] }),
} as Response);

mockUseFeedConversion.mockReturnValue({
isConverting: false,
result: {
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',
Expand Down Expand Up @@ -196,7 +283,7 @@ describe('App', () => {
expect(mockSaveToken).toHaveBeenCalledWith('token-123');
expect(mockConvertFeed).toHaveBeenCalledWith(
'https://example.com/articles',
'ssrf_filter',
'browserless',
'token-123'
);
});
Expand Down Expand Up @@ -281,4 +368,22 @@ describe('App', () => {
expect(bookmarklet.getAttribute('href')).toContain('/?url=');
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
});

it('shows the utility links in a user-focused order', () => {
render(<App />);

fireEvent.click(screen.getByRole('button', { name: 'More' }));

const utilityLinks = screen.getAllByRole('link').map((link) => link.textContent);
expect(utilityLinks).toEqual(['Try included feeds', 'Bookmarklet', 'OpenAPI spec', 'Source code']);

expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute(
'href',
'http://example.test/openapi.yaml'
);
expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute(
'href',
'https://html2rss.github.io/web-application/how-to/use-included-configs/'
);
});
});
Loading
Loading