Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
end
end
Expand Down
37 changes: 31 additions & 6 deletions app/web/config/local_config.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# frozen_string_literal: true

require 'yaml'
begin
require 'html2rss/configs'
rescue LoadError => error
warn "[html2rss-web] Failed to load 'html2rss/configs': #{error.message}"
raise
end

module Html2rss
module Web
Expand All @@ -17,6 +23,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 +34,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 +81,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 +115,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
53 changes: 49 additions & 4 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,58 @@ import { expect, test } from '@playwright/test';

test.describe('frontend smoke', () => {
test('loads create flow and inline access-token gate', async ({ page }) => {
await page.route(/\/api\/v1$/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
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: true,
access_token_required: true,
},
featured_feeds: [],
},
},
}),
});
});

await page.route(/\/api\/v1\/strategies$/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
strategies: [
{ id: 'faraday', name: 'faraday', display_name: 'Default' },
{
id: 'browserless',
name: 'browserless',
display_name: 'JavaScript pages (recommended)',
},
],
},
meta: { total: 2 },
}),
});
});

await page.goto('/');

await expect(page.getByLabel('PAGE URL')).toBeVisible();
await expect(page.getByLabel('Page URL')).toBeVisible();
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible();
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();

await page.getByLabel('PAGE URL').fill('https://example.com/articles');
await page.getByLabel('Page URL').fill('https://example.com/articles');
await page.getByRole('button', { name: 'Generate feed URL' }).click();

await expect(page.getByRole('heading', { name: 'Add access token' })).toBeVisible();
Expand All @@ -18,6 +63,6 @@ test.describe('frontend smoke', () => {

await page.getByRole('button', { name: 'Back' }).click();
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible();
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
});
});
12 changes: 8 additions & 4 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.93.1",
"@preact/preset-vite": "^2.10.2",
"@playwright/test": "^1.58.2",
"@preact/preset-vite": "^2.10.2",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/preact": "^3.2.4",
"baseline-browser-mapping": "^2.10.9",
"jsdom": "^27.0.0",
"msw": "^2.11.3",
"prettier": "^3.x.x",
Expand Down
18 changes: 16 additions & 2 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,6 +54,9 @@ 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' } });
Expand Down Expand Up @@ -89,6 +99,7 @@ describe('App contract', () => {
enabled: true,
access_token_required: true,
},
featured_feeds: [],
},
},
});
Expand Down Expand Up @@ -135,6 +146,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
Loading
Loading