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: 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..639c1da3 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' @@ -11,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 ## @@ -134,32 +127,18 @@ 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 - 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, - **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/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_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/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..73c38bc6 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 ## @@ -18,27 +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 - 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 - end - # @param error [StandardError] # @param event_name [String] # @param outcome [String] 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..cde0cd5a 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.

+ +

{ + const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); + if (typeof window === 'undefined') return directoryUrl.toString(); + + const instanceUrl = new URL('/', window.location.origin); + directoryUrl.hash = `!url=${encodeURIComponent(instanceUrl.toString())}`; + return directoryUrl.toString(); + })(); if (hidden) return null; @@ -270,12 +282,7 @@ export function UtilityStrip({ {isOpen && (

- + Try included feeds diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index b245f868..0389addc 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -345,6 +345,55 @@ a:focus-visible { font-weight: var(--text-strong); } +.notice__intro, +.notice__meta { + color: var(--text-muted); +} + +.featured-feed-list { + display: grid; + gap: var(--space-3); +} + +.featured-feed-item { + padding: var(--space-3); + border: var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-elevated); +} + +.featured-feed-item__link { + display: grid; + gap: var(--space-1); + transition: + border-color var(--transition-fast), + background-color var(--transition-fast), + transform var(--transition-fast); +} + +.featured-feed-item:hover, +.featured-feed-item:focus-within { + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.065); +} + +.featured-feed-item__link:hover, +.featured-feed-item__link:focus-visible { + text-decoration: none; + transform: translateY(-0.04rem); +} + +.featured-feed-item__title { + color: var(--text-strong); + font-size: var(--font-size-1); + font-weight: 600; +} + +.featured-feed-item__description { + color: var(--text-muted); + line-height: 1.4; +} + .notice[data-tone="error"] { border-color: rgba(248, 113, 113, 0.22); background: var(--bg-danger); diff --git a/public/shared-ui.css b/public/shared-ui.css index 29145f8d..003588a4 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -1,8 +1,8 @@ /* Shared design-system primitives for both the app UI and RSS/XSL surfaces. See docs/design-system.md before changing this file. */ :root { color-scheme: dark; - --font-family-ui: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; - --font-family-display: "Fraunces", "Iowan Old Style", "Georgia", serif; + --font-family-ui: system-ui, -apple-system, "Segoe UI", sans-serif; + --font-family-display: system-ui, -apple-system, "Segoe UI", sans-serif; --font-family-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; --font-size-00: 0.8125rem; --font-size-0: 0.9375rem; diff --git a/spec/html2rss/web/log_sanitizer_spec.rb b/spec/html2rss/web/log_sanitizer_spec.rb new file mode 100644 index 00000000..1d743684 --- /dev/null +++ b/spec/html2rss/web/log_sanitizer_spec.rb @@ -0,0 +1,97 @@ +# 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_event' +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) + 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