Skip to content

Commit d3f5899

Browse files
committed
.
1 parent f3c0f45 commit d3f5899

File tree

12 files changed

+634
-477
lines changed

12 files changed

+634
-477
lines changed

.github/copilot-instructions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ Fix rubocop `RSpec/MultipleExpectations` adding rspec tag `:aggregate_failures`.
3535

3636
## Core Rules
3737

38-
-Use **Roda routing with `hash_branch`**. Keep routes small.
39-
-Put logic into `helpers/` or `app/`, not inline in routes.
38+
-Organise Roda routes via dedicated modules (e.g. `Html2rss::Web::Routes::*`), keeping the main app class thin.
39+
-Keep helper modules minimal: define entrypoints with `class << self` and push implementation helpers under `private`; avoid `module_function` unless mirroring existing conventions.
4040
- ✅ Validate all inputs. Pass outbound requests through **SSRF filter**.
4141
- ✅ Add caching headers where appropriate (`Rack::Cache`).
4242
- ✅ Errors: friendly messages for users, detailed logging internally.
@@ -50,7 +50,7 @@ Fix rubocop `RSpec/MultipleExpectations` adding rspec tag `:aggregate_failures`.
5050
- ❌ Don't bypass SSRF filter or weaken CSP.
5151
- ❌ Don't add databases, ORMs, or background jobs.
5252
- ❌ Don't leak stack traces or secrets in responses.
53-
- ❌ Don't test private methods using `send(...)`
53+
- ❌ Don't reach into private API with `send(...)`; expose what you need at the module level instead.
5454
- ❌ Don't modify `frontend/dist/` - it's generated by build process.
5555
- ❌ NEVER expose the auth token a user provides.
5656

app.rb

Lines changed: 12 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
require_relative 'app/api/v1/strategies'
2020
require_relative 'app/ssrf_filter_strategy'
2121
require_relative 'app/http_cache'
22+
require_relative 'app/routes/api_v1'
23+
require_relative 'app/routes/static'
2224

2325
module Html2rss
2426
module Web
@@ -115,60 +117,10 @@ def development? = self.class.development?
115117
route do |r|
116118
r.public
117119

118-
r.on 'api', 'v1' do # rubocop:disable Metrics/BlockLength
119-
r.response['Content-Type'] = 'application/json'
120-
121-
r.on 'health' do
122-
r.get do
123-
JSON.generate(Api::V1::Health.show(r))
124-
end
125-
end
126-
127-
r.on 'strategies' do
128-
r.get do
129-
JSON.generate(Api::V1::Strategies.index(r))
130-
end
131-
end
132-
133-
r.on 'feeds' do
134-
r.get String do |token|
135-
result = Api::V1::Feeds.show(r, token)
136-
result.is_a?(Hash) ? JSON.generate(result) : result
137-
end
138-
r.post do
139-
JSON.generate(Api::V1::Feeds.create(r))
140-
end
141-
end
142-
143-
r.get 'docs' do
144-
docs_path = 'docs/api/v1/openapi.yaml'
145-
if File.exist?(docs_path)
146-
r.response['Content-Type'] = 'text/yaml'
147-
File.read(docs_path)
148-
else
149-
r.response.status = 404
150-
JSON.generate({ success: false, error: { message: 'Documentation not found' } })
151-
end
152-
end
153-
154-
r.get do
155-
JSON.generate({ success: true,
156-
data: { api: { name: 'html2rss-web API',
157-
description: 'RESTful API for converting websites to RSS feeds' } } })
158-
end
159-
end
160-
161-
r.get String do |feed_name|
162-
next if feed_name.include?('.') && !feed_name.end_with?('.xml', '.rss')
163-
164-
handle_feed_generation(r, feed_name)
165-
end
166-
167-
r.root do
168-
index_path = 'public/frontend/index.html'
169-
response['Content-Type'] = 'text/html'
170-
File.exist?(index_path) ? File.read(index_path) : fallback_html
171-
end
120+
Routes::ApiV1.call(r)
121+
Routes::Static.call(r,
122+
feed_handler: ->(router_ctx, feed_name) { handle_feed_generation(router_ctx, feed_name) },
123+
index_renderer: ->(router_ctx) { render_index_page(router_ctx) })
172124
end
173125

174126
private
@@ -181,6 +133,12 @@ def handle_feed_generation(router, feed_name)
181133
rss_content
182134
end
183135

136+
def render_index_page(router)
137+
index_path = 'public/frontend/index.html'
138+
router.response['Content-Type'] = 'text/html'
139+
File.exist?(index_path) ? File.read(index_path) : fallback_html
140+
end
141+
184142
def fallback_html
185143
<<~HTML
186144
<!DOCTYPE html>

app/api/v1/feeds.rb

Lines changed: 97 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -17,151 +17,135 @@ module Api
1717
module V1
1818
# RESTful API v1 for feeds
1919
module Feeds
20-
module_function
20+
class << self
21+
def show(request, token)
22+
ensure_auto_source_enabled!
2123

22-
def show(request, token)
23-
handle_token_based_feed(request, token)
24-
end
24+
feed_token = validated_token_for(token)
25+
account = account_for(feed_token)
26+
ensure_access!(account, feed_token.url)
2527

26-
def create(request)
27-
raise ForbiddenError, 'Auto source feature is disabled' unless AutoSource.enabled?
28+
render_generated_feed(request, feed_token.url)
29+
end
2830

29-
account = authenticate_request(request)
30-
params = extract_create_params(request)
31-
validate_create_params(params, account)
31+
def create(request)
32+
ensure_auto_source_enabled!
3233

33-
feed_data = AutoSource.create_stable_feed(params[:name], params[:url], account, params[:strategy])
34-
raise InternalServerError, 'Failed to create feed' unless feed_data
34+
account = require_account(request)
35+
params = build_create_params(request, account)
3536

36-
build_create_response(request, feed_data)
37-
end
37+
feed_data = AutoSource.create_stable_feed(params[:name], params[:url], account, params[:strategy])
38+
raise InternalServerError, 'Failed to create feed' unless feed_data
3839

39-
def handle_token_based_feed(request, token)
40-
raise ForbiddenError, 'Auto source feature is disabled' unless AutoSource.enabled?
40+
json_response(request, feed_response_payload(feed_data), status: 201)
41+
end
4142

42-
feed_token = validate_feed_token(token)
43-
account = get_account_for_token(feed_token)
44-
validate_account_access(account, feed_token.url)
43+
private
4544

46-
generate_feed_response(request, feed_token.url)
47-
end
45+
def ensure_auto_source_enabled!
46+
raise ForbiddenError, 'Auto source feature is disabled' unless AutoSource.enabled?
47+
end
4848

49-
def validate_feed_token(token)
50-
feed_token = FeedToken.decode(token)
51-
raise UnauthorizedError, 'Invalid token' unless feed_token
49+
def json_response(request, payload, status: 200)
50+
request.response['Content-Type'] = 'application/json'
51+
request.response.status = status
52+
payload
53+
end
5254

53-
validated_token = FeedToken.validate_and_decode(token, feed_token.url, Auth.secret_key)
54-
raise UnauthorizedError, 'Invalid token' unless validated_token
55+
def build_create_params(request, account)
56+
url = request.params['url'].to_s.strip
57+
raise BadRequestError, 'URL parameter is required' if url.empty?
58+
raise BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url)
59+
raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account, url)
5560

56-
validated_token
57-
end
61+
{
62+
url: url,
63+
name: extract_site_title(url),
64+
strategy: normalize_strategy(request.params['strategy'])
65+
}
66+
end
5867

59-
def get_account_for_token(feed_token)
60-
account = AccountManager.get_account_by_username(feed_token.username)
61-
raise UnauthorizedError, 'Account not found' unless account
68+
def normalize_strategy(raw_strategy)
69+
strategy = raw_strategy.to_s.strip
70+
strategy = default_strategy if strategy.empty?
6271

63-
account
64-
end
72+
raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy)
6573

66-
def validate_account_access(account, url)
67-
raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url)
68-
end
69-
70-
def generate_feed_response(request, url)
71-
strategy = select_strategy(request.params['strategy'])
72-
rss_content = AutoSource.generate_feed_content(url, strategy)
74+
strategy
75+
end
7376

74-
request.response['Content-Type'] = 'application/xml'
77+
def validated_token_for(token)
78+
feed_token = Auth.validate_and_decode_feed_token(token)
79+
raise UnauthorizedError, 'Invalid token' unless feed_token
7580

76-
# TODO: get ttl from feed
77-
HttpCache.expires(request.response, 600, cache_control: 'public')
81+
feed_token
82+
end
7883

79-
rss_content.to_s
80-
end
84+
def account_for(feed_token)
85+
account = AccountManager.get_account_by_username(feed_token.username)
86+
raise UnauthorizedError, 'Account not found' unless account
8187

82-
def authenticate_request(request)
83-
account = Auth.authenticate(request)
84-
raise UnauthorizedError, 'Authentication required' unless account
88+
account
89+
end
8590

86-
account
87-
end
91+
def ensure_access!(account, url)
92+
raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url)
93+
end
8894

89-
private
95+
def render_generated_feed(request, url)
96+
rss_content = AutoSource.generate_feed_content(url, normalize_strategy(request.params['strategy']))
9097

91-
def extract_create_params(request)
92-
url = request.params['url']
93-
strategy = select_strategy(request.params['strategy'])
94-
{
95-
url: url,
96-
name: extract_site_title(url),
97-
strategy: strategy
98-
}
99-
end
98+
request.response['Content-Type'] = 'application/xml'
10099

101-
def validate_create_params(params, account)
102-
raise BadRequestError, 'URL parameter is required' if params[:url].nil? || params[:url].empty?
103-
raise BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(params[:url])
104-
raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account,
105-
params[:url])
106-
end
100+
# TODO: get ttl from feed
101+
HttpCache.expires(request.response, 600, cache_control: 'public')
107102

108-
def build_create_response(request, feed_data)
109-
request.response['Content-Type'] = 'application/json'
110-
request.response.status = 201
111-
feed_response_payload(feed_data)
112-
end
103+
rss_content.to_s
104+
end
113105

114-
def select_strategy(raw_strategy)
115-
strategy = raw_strategy.to_s.strip
116-
strategy = default_strategy if strategy.empty?
106+
def require_account(request)
107+
account = Auth.authenticate(request)
108+
raise UnauthorizedError, 'Authentication required' unless account
117109

118-
raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy)
110+
account
111+
end
119112

120-
strategy
121-
end
113+
def supported_strategies
114+
Html2rss::RequestService.strategy_names.map(&:to_s)
115+
end
122116

123-
def supported_strategies
124-
Html2rss::RequestService.strategy_names.map(&:to_s)
125-
end
117+
def default_strategy
118+
Html2rss::RequestService.default_strategy_name.to_s
119+
end
126120

127-
def default_strategy
128-
Html2rss::RequestService.default_strategy_name.to_s
129-
end
121+
def feed_response_payload(feed_data)
122+
{
123+
success: true,
124+
data: { feed: feed_attributes(feed_data) },
125+
meta: { created: true }
126+
}
127+
end
130128

131-
def feed_response_payload(feed_data)
132-
{
133-
success: true,
134-
data: { feed: feed_attributes(feed_data) },
135-
meta: { created: true }
136-
}
137-
end
129+
def feed_attributes(feed_data)
130+
timestamp = Time.now.iso8601
138131

139-
def feed_attributes(feed_data)
140-
timestamp = Time.now.iso8601
141-
142-
{
143-
id: feed_data[:id],
144-
name: feed_data[:name],
145-
url: feed_data[:url],
146-
strategy: feed_data[:strategy],
147-
public_url: feed_data[:public_url],
148-
created_at: timestamp,
149-
updated_at: timestamp
150-
}
151-
end
132+
{
133+
id: feed_data[:id],
134+
name: feed_data[:name],
135+
url: feed_data[:url],
136+
strategy: feed_data[:strategy],
137+
public_url: feed_data[:public_url],
138+
created_at: timestamp,
139+
updated_at: timestamp
140+
}
141+
end
152142

153-
def extract_site_title(url)
154-
Html2rss::Url.for_channel(url).channel_titleized
155-
rescue StandardError
156-
nil
143+
def extract_site_title(url)
144+
Html2rss::Url.for_channel(url).channel_titleized
145+
rescue StandardError
146+
nil
147+
end
157148
end
158-
159-
module_function :extract_create_params, :validate_create_params, :build_create_response,
160-
:authenticate_request, :select_strategy, :supported_strategies, :default_strategy,
161-
:feed_response_payload, :feed_attributes, :extract_site_title
162-
private_class_method :extract_create_params, :validate_create_params, :build_create_response,
163-
:authenticate_request, :select_strategy, :supported_strategies, :default_strategy,
164-
:feed_response_payload, :feed_attributes, :extract_site_title
165149
end
166150
end
167151
end

0 commit comments

Comments
 (0)