From bf67db6d71b137dd15d7f8b74e40eeb576189b57 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 15:27:27 +0100 Subject: [PATCH 1/6] Link included feeds to instance feed directory --- frontend/src/__tests__/App.test.tsx | 3 ++- frontend/src/components/AppPanels.tsx | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index af6bf619..87703b90 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -400,6 +400,7 @@ describe('App', () => { }); it('shows the utility links in a user-focused order', () => { + window.history.replaceState({}, '', 'http://localhost:3000/#result'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); @@ -419,7 +420,7 @@ describe('App', () => { ); expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute( 'href', - 'https://html2rss.github.io/web-application/how-to/use-included-configs/' + 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); expect(screen.getByRole('link', { name: 'Install from Docker Hub' })).toHaveAttribute( 'href', diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 59a8fd95..19d6f51d 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -255,6 +255,16 @@ export function UtilityStrip({ onClearToken, }: UtilityStripProps) { const [isOpen, setIsOpen] = useState(false); + const includedFeedsHref = (() => { + const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); + if (typeof window === 'undefined') return directoryUrl.toString(); + + const instanceUrl = new URL(window.location.href); + instanceUrl.search = ''; + instanceUrl.hash = ''; + directoryUrl.hash = `!url=${encodeURIComponent(instanceUrl.toString())}`; + return directoryUrl.toString(); + })(); if (hidden) return null; @@ -271,7 +281,7 @@ export function UtilityStrip({ {isOpen && (
Date: Sat, 21 Mar 2026 15:37:01 +0100 Subject: [PATCH 2/6] Refine included feed presentation --- frontend/src/components/AppPanels.tsx | 26 +++++++------- frontend/src/styles/main.css | 49 +++++++++++++++++++++++++++ public/shared-ui.css | 4 +-- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 19d6f51d..1aaa6582 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -152,16 +152,20 @@ export function CreateFeedPanel({ role="status" aria-label="Included feeds" > +

Included feeds

Try a working included feed
-

Start with one of the embedded configs from this instance:

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

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

- ))} -

+

Start with a ready-made feed from this instance.

+ +

Date: Sat, 21 Mar 2026 15:47:40 +0100 Subject: [PATCH 3/6] Redact sensitive feed data from logs --- app/web/boot/setup.rb | 8 ++ app/web/request/request_context_middleware.rb | 2 +- app/web/security/security_logger.rb | 14 +-- app/web/telemetry/app_logger.rb | 92 +++++++++++++++++ app/web/telemetry/log_sanitizer.rb | 61 ++++++++++++ app/web/telemetry/observability.rb | 17 +--- spec/html2rss/web/log_sanitizer_spec.rb | 98 +++++++++++++++++++ .../web/request_context_middleware_spec.rb | 14 +++ 8 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 app/web/telemetry/app_logger.rb create mode 100644 app/web/telemetry/log_sanitizer.rb create mode 100644 spec/html2rss/web/log_sanitizer_spec.rb diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index fd69faa8..edb5687d 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -13,6 +13,7 @@ class << self def call! validate_environment! configure_request_service! + configure_runtime_logging! end private @@ -28,6 +29,13 @@ def validate_environment! def configure_request_service! nil end + + # @return [void] + def configure_runtime_logging! + return unless defined?(Rack::Timeout::Logger) + + Rack::Timeout::Logger.logger = AppLogger.logger + end end end end diff --git a/app/web/request/request_context_middleware.rb b/app/web/request/request_context_middleware.rb index ad49ad75..745eecb8 100644 --- a/app/web/request/request_context_middleware.rb +++ b/app/web/request/request_context_middleware.rb @@ -57,7 +57,7 @@ def build_context(request) path = request.path_info.to_s RequestContext::Context.new( request_id: request_id_for(request), - path: path, + path: LogSanitizer.sanitize_path(path), http_method: request.request_method.to_s.upcase, route_group: route_group_for(path), actor: nil, diff --git a/app/web/security/security_logger.rb b/app/web/security/security_logger.rb index 1d15d495..c5ed4a7e 100644 --- a/app/web/security/security_logger.rb +++ b/app/web/security/security_logger.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'logger' require 'json' require 'digest' require 'time' @@ -135,16 +134,7 @@ def log_cache_lifecycle(component, event, details = {}) private def create_logger - Logger.new($stdout).tap do |log| - log.formatter = proc do |severity, datetime, _progname, msg| - "#{{ - timestamp: datetime.iso8601, - level: severity, - service: 'html2rss-web', - **JSON.parse(msg, symbolize_names: true) - }.to_json}\n" - end - end + AppLogger.logger end ## @@ -156,7 +146,7 @@ def log_event(event_type, data, severity: :warn) payload = { security_event: event_type, **context_data, - **data + **LogSanitizer.sanitize_details(data) }.to_json logger.public_send(severity, payload) diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb new file mode 100644 index 00000000..f267fb5b --- /dev/null +++ b/app/web/telemetry/app_logger.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'json' +require 'logger' +require 'time' +require 'uri' + +module Html2rss + module Web + ## + # Shared structured logger for application and middleware runtime events. + module AppLogger + class << self + # @return [Logger] + def logger + Thread.current[:app_logger] ||= build_logger + end + + # @return [void] + def reset_logger! + Thread.current[:app_logger] = nil + end + + private + + # @return [Logger] + def build_logger + Logger.new($stdout).tap do |log| + log.formatter = method(:format_entry) + end + end + + # @param severity [String] + # @param datetime [Time] + # @param _progname [String, nil] + # @param message [String] + # @return [String] + def format_entry(severity, datetime, _progname, message) + "#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n" + end + + # @param severity [String] + # @param datetime [Time] + # @return [Hash{Symbol=>Object}] + def base_payload(severity, datetime) + { + timestamp: datetime.iso8601, + level: severity, + service: 'html2rss-web' + } + end + + # @param message [Object] + # @return [Hash{Symbol=>Object}] + def normalize_message(message) + parsed_json(message) || parse_logfmt(message.to_s) || { message: message.to_s } + end + + # @param message [Object] + # @return [Hash{Symbol=>Object}, nil] + def parsed_json(message) + JSON.parse(message.to_s, symbolize_names: true) + rescue JSON::ParserError, TypeError + nil + end + + # @param message [String] + # @return [Hash{Symbol=>Object}, nil] + def parse_logfmt(message) + pairs = message.scan(/([a-zA-Z0-9_.-]+)=("[^"]*"|\S+)/) + return nil if pairs.empty? + + pairs.to_h do |key, raw_value| + [key.to_sym, normalize_logfmt_value(raw_value)] + end + end + + # @param raw_value [String] + # @return [String, Integer, Float, TrueClass, FalseClass] + def normalize_logfmt_value(raw_value) + value = raw_value.delete_prefix('"').delete_suffix('"') + return true if value == 'true' + return false if value == 'false' + return value.to_i if value.match?(/\A-?\d+\z/) + return value.to_f if value.match?(/\A-?\d+\.\d+\z/) + + value + end + end + end + end +end diff --git a/app/web/telemetry/log_sanitizer.rb b/app/web/telemetry/log_sanitizer.rb new file mode 100644 index 00000000..67c25b75 --- /dev/null +++ b/app/web/telemetry/log_sanitizer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'digest' +require 'uri' + +module Html2rss + module Web + ## + # Sanitizes request and detail payloads before structured logging. + module LogSanitizer + FEED_TOKEN_ROUTE = %r{\A(/api/v1/feeds/)([^/.?]+)(\.(?:json|xml|rss))?\z} + + class << self + # @param path [String, nil] + # @return [String, nil] + def sanitize_path(path) + return if path.nil? + + path.to_s.gsub(FEED_TOKEN_ROUTE, '\1[REDACTED]\3') + end + + # @param details [Hash] + # @return [Hash] + def sanitize_details(details) + details.each_with_object({}) do |(key, value), sanitized| + sanitized[key] = sanitize_value(key, value) + end + end + + private + + # @param key [Object] + # @param value [Object] + # @return [Object] + def sanitize_value(key, value) + return sanitize_url(value) if key.to_sym == :url + return sanitize_details(value) if value.is_a?(Hash) + return value.map { |entry| sanitize_value(key, entry) } if value.is_a?(Array) + + value + end + + # @param value [Object] + # @return [Hash{Symbol=>Object}, Object] + def sanitize_url(value) + url = value.to_s + return value if url.empty? + + uri = URI.parse(url) + { + host: uri.host, + scheme: uri.scheme, + hash: Digest::SHA256.hexdigest(url)[0..11] + }.compact + rescue URI::InvalidURIError + { hash: Digest::SHA256.hexdigest(url)[0..11] } + end + end + end + end +end diff --git a/app/web/telemetry/observability.rb b/app/web/telemetry/observability.rb index 496a0c9b..6400db8a 100644 --- a/app/web/telemetry/observability.rb +++ b/app/web/telemetry/observability.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require 'json' -require 'logger' -require 'time' - module Html2rss module Web ## @@ -27,16 +23,7 @@ def emit(event_name:, outcome:, details: {}, level: :info) # @return [Logger] def logger - Thread.current[:observability_logger] ||= Logger.new($stdout).tap do |log| - log.formatter = proc do |severity, datetime, _progname, msg| - "#{{ - timestamp: datetime.iso8601, - level: severity, - service: 'html2rss-web', - **JSON.parse(msg, symbolize_names: true) - }.to_json}\n" - end - end + AppLogger.logger end # @param error [StandardError] @@ -54,7 +41,7 @@ def handle_emit_error(error, event_name, outcome) # @return [Hash{Symbol=>Object}] def build_payload(event_name, outcome, details) context = RequestContext.current_h - base_payload(event_name, outcome, context).merge(details: details) + base_payload(event_name, outcome, context).merge(details: LogSanitizer.sanitize_details(details)) end # @param event_name [String] diff --git a/spec/html2rss/web/log_sanitizer_spec.rb b/spec/html2rss/web/log_sanitizer_spec.rb new file mode 100644 index 00000000..3483a426 --- /dev/null +++ b/spec/html2rss/web/log_sanitizer_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' + +require_relative '../../../app/web/request/request_context' +require_relative '../../../app/web/security/security_logger' +require_relative '../../../app/web/telemetry/app_logger' +require_relative '../../../app/web/telemetry/log_sanitizer' +require_relative '../../../app/web/telemetry/observability' + +RSpec.describe Html2rss::Web::LogSanitizer do + let(:io) { StringIO.new } + let(:logger) { Logger.new(io).tap { |log| log.formatter = Html2rss::Web::AppLogger.send(:method, :format_entry) } } + let(:context) do + Html2rss::Web::RequestContext::Context.new( + request_id: 'req-123', + path: '/api/v1/feeds/[REDACTED]', + http_method: 'GET', + route_group: 'api_v1', + actor: nil, + strategy: 'faraday', + started_at: '2026-03-21T00:00:00Z' + ) + end + + before do + Html2rss::Web::RequestContext.set!(context) + Html2rss::Web::AppLogger.reset_logger! + Html2rss::Web::SecurityLogger.reset_logger! + allow(Html2rss::Web::AppLogger).to receive(:logger).and_return(logger) + allow(Html2rss::Web::SecurityLogger).to receive(:logger).and_return(logger) + allow(Html2rss::Web::Observability).to receive(:logger).and_return(logger) + end + + after do + Html2rss::Web::RequestContext.clear! + end + + it 'redacts feed tokens from token feed request paths' do + expect(described_class.sanitize_path('/api/v1/feeds/token-value-123')).to eq('/api/v1/feeds/[REDACTED]') + expect(described_class.sanitize_path('/api/v1/feeds/token-value-123.json')).to eq('/api/v1/feeds/[REDACTED].json') + end + + it 'replaces logged urls with hashed host metadata' do + expected_url = { + host: 'news.ycombinator.com', + scheme: 'https', + hash: Digest::SHA256.hexdigest('https://news.ycombinator.com')[0..11] + } + + expect(described_class.sanitize_details(url: 'https://news.ycombinator.com')).to eq(url: expected_url) + end + + it 'sanitizes security logger token usage fields' do + Html2rss::Web::SecurityLogger.log_token_usage('very-secret-token', 'https://news.ycombinator.com', true) + payload = JSON.parse(io.string.lines.last, symbolize_names: true) + + expect(payload.slice(:path, :url, :token_hash)).to eq( + path: '/api/v1/feeds/[REDACTED]', + url: { + host: 'news.ycombinator.com', + scheme: 'https', + hash: Digest::SHA256.hexdigest('https://news.ycombinator.com')[0..11] + }, + token_hash: Digest::SHA256.hexdigest('very-secret-token')[0..7] + ) + end + + it 'sanitizes observability details' do + Html2rss::Web::Observability.emit( + event_name: 'feed.render', + outcome: 'success', + details: { url: 'https://news.ycombinator.com', strategy: 'faraday' } + ) + + lines = io.string.lines.map { |line| JSON.parse(line, symbolize_names: true) } + observability_payload = lines.first + + expect(observability_payload.dig(:details, :url)).to eq( + host: 'news.ycombinator.com', + scheme: 'https', + hash: Digest::SHA256.hexdigest('https://news.ycombinator.com')[0..11] + ) + end + + it 'formats rack-timeout logfmt as json' do + logger.info('source=rack-timeout id=req-123 timeout=15000ms state=completed') + + payload = JSON.parse(io.string.lines.last, symbolize_names: true) + expect(payload).to include( + source: 'rack-timeout', + id: 'req-123', + timeout: '15000ms', + state: 'completed' + ) + end +end diff --git a/spec/html2rss/web/request_context_middleware_spec.rb b/spec/html2rss/web/request_context_middleware_spec.rb index 6aae8308..f0e55c73 100644 --- a/spec/html2rss/web/request_context_middleware_spec.rb +++ b/spec/html2rss/web/request_context_middleware_spec.rb @@ -17,6 +17,11 @@ expect(response['X-Request-Id']).not_to be_empty end + it 'redacts feed tokens from request context paths' do + response = Rack::MockRequest.new(redaction_app).get('/api/v1/feeds/sensitive-token-value.json') + expect(response.body).to eq('/api/v1/feeds/[REDACTED].json') + end + private # @return [Html2rss::Web::RequestContextMiddleware] @@ -27,4 +32,13 @@ def middleware_app end described_class.new(app) end + + # @return [Html2rss::Web::RequestContextMiddleware] + def redaction_app + app = lambda do |_env| + context = Html2rss::Web::RequestContext.current + [200, { 'Content-Type' => 'text/plain' }, [context.path]] + end + described_class.new(app) + end end From 59cc36211a3964cca287e15326879751460e2850 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 15:53:13 +0100 Subject: [PATCH 4/6] Consolidate structured log emission --- app/web/security/security_logger.rb | 29 +++++++---------------- app/web/telemetry/log_event.rb | 31 +++++++++++++++++++++++++ app/web/telemetry/observability.rb | 9 ++----- spec/html2rss/web/log_sanitizer_spec.rb | 3 +-- 4 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 app/web/telemetry/log_event.rb diff --git a/app/web/security/security_logger.rb b/app/web/security/security_logger.rb index c5ed4a7e..639c1da3 100644 --- a/app/web/security/security_logger.rb +++ b/app/web/security/security_logger.rb @@ -10,16 +10,10 @@ module Web # Provides structured logging for security events to stdout module SecurityLogger class << self - # Initialize logger to stdout with structured JSON output - # @return [Logger] - def logger - Thread.current[:security_logger] ||= create_logger - end - - # Reset logger (for testing) + # Reset shared logger state for tests. # @return [void] def reset_logger! - Thread.current[:security_logger] = nil + AppLogger.reset_logger! end ## @@ -133,23 +127,18 @@ def log_cache_lifecycle(component, event, details = {}) private - def create_logger - AppLogger.logger - end - ## # Log a security event # @param event_type [String] type of security event # @param data [Hash] event data def log_event(event_type, data, severity: :warn) - context_data = RequestContext.current_h - payload = { - security_event: event_type, - **context_data, - **LogSanitizer.sanitize_details(data) - }.to_json - - logger.public_send(severity, payload) + LogEvent.emit( + level: severity, + payload: { + security_event: event_type, + **data + } + ) rescue StandardError => error handle_logging_error(error, event_type, data) end diff --git a/app/web/telemetry/log_event.rb b/app/web/telemetry/log_event.rb new file mode 100644 index 00000000..0b85a561 --- /dev/null +++ b/app/web/telemetry/log_event.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Shared structured log emitter for request-scoped application events. + module LogEvent + class << self + # @param payload [Hash{Symbol=>Object}] + # @param level [Symbol] + # @return [void] + def emit(payload:, level: :info) + logger.public_send(level, build_payload(payload).to_json) + end + + private + + # @return [Logger] + def logger + AppLogger.logger + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def build_payload(payload) + RequestContext.current_h.merge(LogSanitizer.sanitize_details(payload)) + end + end + end + end +end diff --git a/app/web/telemetry/observability.rb b/app/web/telemetry/observability.rb index 6400db8a..73c38bc6 100644 --- a/app/web/telemetry/observability.rb +++ b/app/web/telemetry/observability.rb @@ -14,18 +14,13 @@ class << self # @param level [Symbol] # @return [void] def emit(event_name:, outcome:, details: {}, level: :info) - logger.public_send(level, build_payload(event_name, outcome, details).to_json) + LogEvent.emit(payload: build_payload(event_name, outcome, details), level: level) rescue StandardError => error handle_emit_error(error, event_name, outcome) end private - # @return [Logger] - def logger - AppLogger.logger - end - # @param error [StandardError] # @param event_name [String] # @param outcome [String] @@ -41,7 +36,7 @@ def handle_emit_error(error, event_name, outcome) # @return [Hash{Symbol=>Object}] def build_payload(event_name, outcome, details) context = RequestContext.current_h - base_payload(event_name, outcome, context).merge(details: LogSanitizer.sanitize_details(details)) + base_payload(event_name, outcome, context).merge(details: details) end # @param event_name [String] diff --git a/spec/html2rss/web/log_sanitizer_spec.rb b/spec/html2rss/web/log_sanitizer_spec.rb index 3483a426..1d743684 100644 --- a/spec/html2rss/web/log_sanitizer_spec.rb +++ b/spec/html2rss/web/log_sanitizer_spec.rb @@ -6,6 +6,7 @@ require_relative '../../../app/web/request/request_context' require_relative '../../../app/web/security/security_logger' require_relative '../../../app/web/telemetry/app_logger' +require_relative '../../../app/web/telemetry/log_event' require_relative '../../../app/web/telemetry/log_sanitizer' require_relative '../../../app/web/telemetry/observability' @@ -29,8 +30,6 @@ Html2rss::Web::AppLogger.reset_logger! Html2rss::Web::SecurityLogger.reset_logger! allow(Html2rss::Web::AppLogger).to receive(:logger).and_return(logger) - allow(Html2rss::Web::SecurityLogger).to receive(:logger).and_return(logger) - allow(Html2rss::Web::Observability).to receive(:logger).and_return(logger) end after do From 2e7000139c4a8422692e6c636eeecf84fd0f903b Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 15:53:29 +0100 Subject: [PATCH 5/6] Update Mergify queue checks for main branch --- .mergify.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 1ac275dc..ccbd53f6 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -4,10 +4,11 @@ queue_rules: - author=dependabot[bot] merge_conditions: - author=dependabot[bot] - - status-success=docker-test + - check-success=docker-test (false) + - check-success=docker-test (true) - status-success=hadolint - status-success=ruby - - base=master + - base=main merge_method: squash pull_request_rules: From ca9c0f94bc742fcc5695d362a817028097428943 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 15:56:03 +0100 Subject: [PATCH 6/6] style: prettier tsx --- frontend/src/components/AppPanels.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 1aaa6582..cde0cd5a 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -282,12 +282,7 @@ export function UtilityStrip({ {isOpen && (