Skip to content

Commit eddaa09

Browse files
committed
feat!: add namespaced feed pipeline primitives
1 parent 17b6446 commit eddaa09

13 files changed

Lines changed: 663 additions & 27 deletions

app.rb

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,7 @@
66
require 'base64'
77

88
require 'html2rss'
9-
require_relative 'app/environment_validator'
10-
require_relative 'app/auth'
11-
require_relative 'app/auto_source'
12-
require_relative 'app/feeds'
13-
require_relative 'app/local_config'
14-
require_relative 'app/flags'
15-
require_relative 'app/cache_ttl'
16-
require_relative 'app/exceptions'
17-
require_relative 'app/xml_builder'
18-
require_relative 'app/json_feed_builder'
19-
require_relative 'app/feed_response_format'
20-
require_relative 'app/request_target'
21-
require_relative 'app/feed_render_result'
22-
require_relative 'app/error_responder'
23-
require_relative 'app/security_logger'
24-
require_relative 'app/observability'
25-
require_relative 'app/app_context'
26-
require_relative 'app/request_context_middleware'
27-
require_relative 'app/api/v1/feeds'
28-
require_relative 'app/api/v1/health'
29-
require_relative 'app/api/v1/strategies'
30-
require_relative 'app/ssrf_filter_strategy'
31-
require_relative 'app/http_cache'
32-
require_relative 'app/feed_request_handler'
33-
require_relative 'app/feed_route_handler'
34-
require_relative 'app/routes/api_v1'
35-
require_relative 'app/routes/static'
9+
Dir[File.join(__dir__, 'app/**/*.rb')].each { |file| require file }
3610

3711
module Html2rss
3812
module Web

app/feeds/cache.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
require 'digest'
4+
require 'time'
5+
6+
require_relative '../cache_ttl'
7+
require_relative '../security_logger'
8+
9+
module Html2rss
10+
module Web
11+
module Feeds
12+
##
13+
# Small synchronous cache for canonical feed results.
14+
module Cache
15+
Entry = Data.define(:result, :expires_at)
16+
17+
class << self
18+
# @param key [String]
19+
# @param ttl_seconds [Integer]
20+
# @yieldreturn [Html2rss::Web::Feeds::Result]
21+
# @return [Html2rss::Web::Feeds::Result]
22+
def fetch(key, ttl_seconds:)
23+
entry = read_entry(key)
24+
return entry.result if fresh?(entry)
25+
26+
result = yield
27+
write_entry(key, ttl_seconds, result)
28+
result
29+
end
30+
31+
# @param reason [String]
32+
# @return [nil]
33+
def clear!(reason: 'manual')
34+
@entries = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
35+
SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason)
36+
nil
37+
end
38+
39+
private
40+
41+
# @param key [String]
42+
# @return [Entry, nil]
43+
def read_entry(key)
44+
entries[key]
45+
end
46+
47+
# @param entry [Entry, nil]
48+
# @return [Boolean]
49+
def fresh?(entry)
50+
entry && Time.now.utc < entry.expires_at
51+
end
52+
53+
# @param key [String]
54+
# @param ttl_seconds [Integer]
55+
# @param result [Html2rss::Web::Feeds::Result]
56+
# @return [void]
57+
def write_entry(key, ttl_seconds, result)
58+
entries[key] = Entry.new(result: result, expires_at: Time.now.utc + normalize_ttl(ttl_seconds))
59+
SecurityLogger.log_cache_lifecycle('feeds_cache', 'write', key_hash: key_hash(key))
60+
end
61+
62+
# @return [Hash{String=>Entry}]
63+
def entries
64+
@entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
65+
end
66+
67+
# @param ttl_seconds [Integer]
68+
# @return [Integer]
69+
def normalize_ttl(ttl_seconds)
70+
ttl_seconds.to_i.positive? ? ttl_seconds.to_i : CacheTtl::DEFAULT_SECONDS
71+
end
72+
73+
# @param key [String]
74+
# @return [String]
75+
def key_hash(key)
76+
Digest::SHA256.hexdigest(key)[0..11]
77+
end
78+
end
79+
end
80+
end
81+
end
82+
end

app/feeds/json_renderer.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
require 'time'
5+
6+
require_relative '../exceptions'
7+
require_relative '../json_feed_builder'
8+
9+
module Html2rss
10+
module Web
11+
module Feeds
12+
##
13+
# Renders JSON Feed output from shared feed results.
14+
module JsonRenderer
15+
VERSION = 'https://jsonfeed.org/version/1.1'
16+
17+
class << self
18+
# @param result [Html2rss::Web::Feeds::Result]
19+
# @return [String]
20+
def call(result)
21+
case result.status
22+
when :ok
23+
JSON.generate(payload_for(result.payload.fetch(:feed)))
24+
when :empty
25+
empty_feed(result)
26+
else
27+
error_feed(result)
28+
end
29+
end
30+
31+
private
32+
33+
# @param result [Html2rss::Web::Feeds::Result]
34+
# @return [String]
35+
def empty_feed(result)
36+
JsonFeedBuilder.build_empty_feed_warning(
37+
url: result.payload.fetch(:url),
38+
strategy: result.payload.fetch(:strategy),
39+
site_title: result.payload.fetch(:feed).channel.title
40+
)
41+
end
42+
43+
# @param result [Html2rss::Web::Feeds::Result]
44+
# @return [String]
45+
def error_feed(result)
46+
JsonFeedBuilder.build_error_feed(message: result.message || HttpError::DEFAULT_MESSAGE)
47+
end
48+
49+
# @param feed [RSS::Rss]
50+
# @return [Hash{Symbol=>Object}]
51+
def payload_for(feed)
52+
{
53+
version: VERSION,
54+
title: feed.channel.title,
55+
home_page_url: feed.channel.link,
56+
description: feed.channel.description,
57+
items: feed.items.map { |item| item_payload(item) }
58+
}.compact
59+
end
60+
61+
# @param item [Object]
62+
# @return [Hash{Symbol=>Object}]
63+
def item_payload(item)
64+
{
65+
id: item.respond_to?(:guid) && item.guid ? item.guid.content : (item.link || item.title),
66+
url: item.link,
67+
title: item.title,
68+
content_text: item.description,
69+
date_published: published_at(item)
70+
}.compact
71+
end
72+
73+
# @param item [Object]
74+
# @return [String, nil]
75+
def published_at(item)
76+
value = item.respond_to?(:pubDate) ? item.pubDate : nil
77+
return value.iso8601 if value.respond_to?(:iso8601)
78+
79+
Time.parse(value.to_s).utc.iso8601 if value
80+
rescue ArgumentError
81+
nil
82+
end
83+
end
84+
end
85+
end
86+
end
87+
end

app/feeds/request.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
module Feeds
6+
##
7+
# Request-edge contract for feed rendering.
8+
Request = Data.define(:target_kind, :representation, :feed_name, :token, :params)
9+
end
10+
end
11+
end

app/feeds/request_parser.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'request'
4+
require_relative 'response_format'
5+
6+
module Html2rss
7+
module Web
8+
module Feeds
9+
##
10+
# Parses route inputs into shared feed request contracts.
11+
module RequestParser
12+
class << self
13+
# @param request [Rack::Request]
14+
# @param target_kind [Symbol]
15+
# @param identifier [String]
16+
# @return [Html2rss::Web::Feeds::Request]
17+
def call(request:, target_kind:, identifier:)
18+
representation = ResponseFormat.for_request(request)
19+
normalized_identifier = ResponseFormat.strip_known_extension(identifier)
20+
21+
Request.new(
22+
target_kind: target_kind,
23+
representation: representation,
24+
feed_name: target_kind == :static ? normalized_identifier : nil,
25+
token: target_kind == :token ? normalized_identifier : nil,
26+
params: request.params.to_h
27+
)
28+
end
29+
end
30+
end
31+
end
32+
end
33+
end

app/feeds/resolved_source.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
module Feeds
6+
##
7+
# Normalized source inputs for shared feed generation.
8+
ResolvedSource = Data.define(:source_kind, :cache_identity, :generator_input, :ttl_seconds)
9+
end
10+
end
11+
end

app/feeds/resolver.rb

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
require 'digest'
4+
5+
require_relative '../account_manager'
6+
require_relative '../auth'
7+
require_relative '../auto_source'
8+
require_relative '../cache_ttl'
9+
require_relative '../exceptions'
10+
require_relative '../local_config'
11+
require_relative '../url_validator'
12+
require_relative '../api/v1/contract'
13+
require_relative 'resolved_source'
14+
15+
module Html2rss
16+
module Web
17+
module Feeds
18+
##
19+
# Resolves static and token-backed requests into shared generator inputs.
20+
module Resolver
21+
class << self
22+
# @param feed_request [Html2rss::Web::Feeds::Request]
23+
# @return [Html2rss::Web::Feeds::ResolvedSource]
24+
def call(feed_request)
25+
case feed_request.target_kind
26+
when :static
27+
resolve_static(feed_request)
28+
when :token
29+
resolve_token(feed_request)
30+
else
31+
raise BadRequestError, "Unsupported feed target: #{feed_request.target_kind}"
32+
end
33+
end
34+
35+
private
36+
37+
# @param feed_request [Html2rss::Web::Feeds::Request]
38+
# @return [Html2rss::Web::Feeds::ResolvedSource]
39+
def resolve_static(feed_request)
40+
config = LocalConfig.find(feed_request.feed_name)
41+
config[:params] = (config[:params] || {}).merge(feed_request.params) if feed_request.params.any?
42+
config[:strategy] ||= Html2rss::RequestService.default_strategy_name
43+
44+
ResolvedSource.new(
45+
source_kind: :static,
46+
cache_identity: static_cache_identity(feed_request.feed_name, feed_request.params),
47+
generator_input: config,
48+
ttl_seconds: CacheTtl.seconds_from_minutes(config.dig(:channel, :ttl))
49+
)
50+
end
51+
52+
# @param feed_request [Html2rss::Web::Feeds::Request]
53+
# @return [Html2rss::Web::Feeds::ResolvedSource]
54+
def resolve_token(feed_request)
55+
feed_token = validated_feed_token(feed_request.token)
56+
strategy = resolved_strategy(feed_token)
57+
generator_input = token_generator_input(feed_token.url, strategy)
58+
59+
ResolvedSource.new(
60+
source_kind: :token,
61+
cache_identity: token_cache_identity(feed_request.token),
62+
generator_input: generator_input,
63+
ttl_seconds: CacheTtl.seconds_from_minutes(generator_input.dig(:channel, :ttl), default: 300)
64+
)
65+
end
66+
67+
# @param feed_name [String]
68+
# @param params [Hash{Object=>Object}]
69+
# @return [String]
70+
def static_cache_identity(feed_name, params)
71+
normalized_params = params.to_h.sort_by { |key, _| key.to_s }
72+
digest = Digest::SHA256.hexdigest(Marshal.dump(normalized_params))
73+
"static:#{feed_name}:#{digest}"
74+
end
75+
76+
# @param token [String]
77+
# @return [String]
78+
def token_cache_identity(token)
79+
"token:#{Digest::SHA256.hexdigest(token.to_s)}"
80+
end
81+
82+
# @param token [String]
83+
# @return [Html2rss::Web::FeedToken]
84+
def validated_feed_token(token)
85+
feed_token = Auth.validate_and_decode_feed_token(token)
86+
raise UnauthorizedError, 'Invalid token' unless feed_token
87+
88+
account = AccountManager.get_account_by_username(feed_token.username)
89+
raise UnauthorizedError, 'Account not found' unless account
90+
91+
ensure_token_access!(account, feed_token.url)
92+
ensure_auto_source_enabled!
93+
feed_token
94+
end
95+
96+
# @param account [Hash{Symbol=>Object}]
97+
# @param url [String]
98+
# @return [void]
99+
def ensure_token_access!(account, url)
100+
raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url)
101+
end
102+
103+
# @return [void]
104+
def ensure_auto_source_enabled!
105+
raise ForbiddenError, Api::V1::Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
106+
end
107+
108+
# @param feed_token [Html2rss::Web::FeedToken]
109+
# @return [String]
110+
def resolved_strategy(feed_token)
111+
strategy = feed_token.strategy.to_s.strip
112+
strategy = Html2rss::RequestService.default_strategy_name.to_s if strategy.empty?
113+
supported = Html2rss::RequestService.strategy_names.map(&:to_s)
114+
raise BadRequestError, 'Unsupported strategy' unless supported.include?(strategy)
115+
116+
strategy
117+
end
118+
119+
# @param url [String]
120+
# @param strategy [String]
121+
# @return [Hash{Symbol=>Object}]
122+
def token_generator_input(url, strategy)
123+
LocalConfig.global
124+
.slice(:stylesheets, :headers)
125+
.merge(
126+
strategy: strategy.to_sym,
127+
channel: { url: url },
128+
auto_source: {}
129+
)
130+
end
131+
end
132+
end
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)