Skip to content

Commit dbad265

Browse files
committed
api/v1, legacy clean, token in url path and zlib'ed
1 parent 85531ba commit dbad265

34 files changed

+918
-1927
lines changed

.github/copilot-instructions.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
- Ruby web app that converts websites into RSS 2.0 feeds.
66
- Built with **Roda** backend + **Astro** frontend, using the **html2rss** gem (+ `html2rss-configs`).
7-
- **Principle:** _All features must work without JavaScript._ JS is only progressive enhancement.
87
- **Frontend:** Modern Astro-based UI with component architecture, served alongside Ruby backend.
98

109
## Documentation website of core dependencies
@@ -46,12 +45,11 @@ Fix rubocop `RSpec/MultipleExpectations` adding rspec tag `:aggregate_failures`.
4645
-**Specs**: RSpec for Ruby, build tests for frontend.
4746

4847
## Don't
49-
50-
- ❌ Don't depend on JS for core flows.
48+
- ❌ Don't use Ruby's URI class or addressable gem directly. Strictly use `Html2rss::Url` only.
5149
- ❌ Don't bypass SSRF filter or weaken CSP.
5250
- ❌ Don't add databases, ORMs, or background jobs.
5351
- ❌ Don't leak stack traces or secrets in responses.
54-
- ❌ Don't add complex frontend frameworks (React, Vue, etc.). Keep Astro simple.
52+
- ❌ Don't test private methods using `send(...)`
5553
- ❌ Don't modify `frontend/dist/` - it's generated by build process.
5654
- ❌ NEVER expose the auth token a user provides.
5755

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,13 @@ curl -X POST "https://your-domain.com/api/v1/feeds" \
102102
"id": "abc123",
103103
"name": "Example Feed",
104104
"url": "https://example.com",
105-
"public_url": "/feeds/abc123?token=...&url=https%3A%2F%2Fexample.com"
105+
"public_url": "/api/v1/feeds/eyJwYXlsb2FkIjoi..."
106106
}
107107
}
108108
}
109109

110-
# Access the feed publicly
111-
curl "https://your-domain.com/feeds/abc123?token=...&url=https%3A%2F%2Fexample.com"
110+
# Access the feed publicly using the signed token
111+
curl "https://your-domain.com/api/v1/feeds/eyJwYXlsb2FkIjoi..."
112112
```
113113

114114
### Security Features

app.rb

Lines changed: 40 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@
2424
module Html2rss
2525
module Web
2626
##
27-
# This app uses html2rss and serves the feeds via HTTP.
28-
#
29-
# It is built with [Roda](https://roda.jeremyevans.net/).
27+
# Roda app serving RSS feeds via html2rss
3028
class App < Roda
3129
CONTENT_TYPE_RSS = 'application/xml'
3230

@@ -54,7 +52,7 @@ def development? = self.class.development?
5452
csp.font_src :self, 'data:'
5553
csp.form_action :self
5654
csp.base_uri :none
57-
csp.frame_ancestors :none
55+
csp.frame_ancestors development? ? ['http://localhost:*', 'https://localhost:*'] : :none
5856
csp.frame_src :self
5957
csp.object_src :none
6058
csp.media_src :none
@@ -66,12 +64,18 @@ def development? = self.class.development?
6664
end
6765

6866
plugin :default_headers, {
69-
'X-Content-Type-Options' => 'nosniff', 'X-XSS-Protection' => '1; mode=block',
70-
'X-Frame-Options' => 'SAMEORIGIN', 'X-Permitted-Cross-Domain-Policies' => 'none',
71-
'Referrer-Policy' => 'strict-origin-when-cross-origin', 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
67+
'X-Content-Type-Options' => 'nosniff',
68+
'X-XSS-Protection' => '1; mode=block',
69+
'X-Frame-Options' => 'SAMEORIGIN',
70+
'X-Permitted-Cross-Domain-Policies' => 'none',
71+
'Referrer-Policy' => 'strict-origin-when-cross-origin',
72+
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
7273
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload',
73-
'Cross-Origin-Embedder-Policy' => 'require-corp', 'Cross-Origin-Opener-Policy' => 'same-origin',
74-
'Cross-Origin-Resource-Policy' => 'same-origin', 'X-DNS-Prefetch-Control' => 'off', 'X-Download-Options' => 'noopen'
74+
'Cross-Origin-Embedder-Policy' => 'require-corp',
75+
'Cross-Origin-Opener-Policy' => 'same-origin',
76+
'Cross-Origin-Resource-Policy' => 'same-origin',
77+
'X-DNS-Prefetch-Control' => 'off',
78+
'X-Download-Options' => 'noopen'
7579
}
7680

7781
plugin :json_parser
@@ -112,18 +116,6 @@ def development? = self.class.development?
112116
route do |r|
113117
r.public
114118

115-
r.get 'feeds.json' do
116-
r.response['Cache-Control'] = 'public, max-age=300'
117-
JSON.generate(Feeds.list_feeds)
118-
end
119-
120-
r.get 'strategies.json' do
121-
r.response['Cache-Control'] = 'public, max-age=3600'
122-
JSON.generate({ strategies: Html2rss::RequestService.strategy_names.map do |name|
123-
{ name: name.to_s, display_name: name.to_s.split('_').map(&:capitalize).join(' ') }
124-
end })
125-
end
126-
127119
r.on 'api', 'v1' do
128120
r.response['Content-Type'] = 'application/json'
129121

@@ -149,8 +141,8 @@ def development? = self.class.development?
149141
end
150142

151143
r.on 'feeds' do
152-
r.get String do |feed_id|
153-
result = Api::V1::Feeds.show(r, feed_id)
144+
r.get String do |token|
145+
result = Api::V1::Feeds.show(r, token)
154146
result.is_a?(Hash) ? JSON.generate(result) : result
155147
end
156148
r.post do
@@ -179,25 +171,13 @@ def development? = self.class.development?
179171
end
180172
end
181173

182-
r.on 'auto_source' do
183-
if AutoSource.enabled?
184-
r.post 'create' do
185-
handle_create_feed(r)
186-
end
174+
# Backward compatibility: /{feed_name} (no auth required)
175+
r.get String do |feed_name|
176+
# Skip static file requests
177+
next if feed_name.include?('.') && !feed_name.end_with?('.xml', '.rss')
187178

188-
r.get String do |encoded_url|
189-
handle_legacy_feed(r, encoded_url)
190-
end
191-
else
192-
r.response.status = 400
193-
'Auto source feature is disabled'
194-
end
195-
end
196-
197-
r.on 'feeds' do
198-
r.get String do |_feed_id|
199-
handle_feed_with_auth(r, r.params['token'])
200-
end
179+
# Route to feed generation without auth for backward compatibility
180+
handle_feed_generation(r, feed_name)
201181
end
202182
r.get 'health_check.txt' do
203183
handle_health_check(r)
@@ -210,7 +190,25 @@ def development? = self.class.development?
210190
end
211191

212192
def fallback_html
213-
'<!DOCTYPE html><html><head><title>html2rss-web</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:2rem;line-height:1.6}h1{color:#111827}code{background:#f3f4f6;padding:0.2rem 0.4rem;border-radius:0.25rem}</style></head><body><h1>html2rss-web</h1><p>Convert websites to RSS feeds</p><p>API available at <code>/api/</code></p></body></html>'
193+
<<~HTML
194+
<!DOCTYPE html>
195+
<html>
196+
<head>
197+
<title>html2rss-web</title>
198+
<meta name="viewport" content="width=device-width,initial-scale=1">
199+
<style>
200+
body{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:2rem;line-height:1.6}
201+
h1{color:#111827}
202+
code{background:#f3f4f6;padding:0.2rem 0.4rem;border-radius:0.25rem}
203+
</style>
204+
</head>
205+
<body>
206+
<h1>html2rss-web</h1>
207+
<p>Convert websites to RSS feeds</p>
208+
<p>API available at <code>/api/</code></p>
209+
</body>
210+
</html>
211+
HTML
214212
end
215213

216214
private
@@ -223,65 +221,6 @@ def handle_feed_generation(router, feed_name)
223221
rss_content
224222
end
225223

226-
def handle_create_feed(router)
227-
account = Auth.authenticate(router)
228-
unless account
229-
router.response.status = 401
230-
return 'Unauthorized'
231-
end
232-
233-
url = router.params['url']
234-
unless url && Auth.valid_url?(url)
235-
router.response.status = 400
236-
return 'URL parameter required'
237-
end
238-
239-
unless Auth.url_allowed?(account, url)
240-
router.response.status = 403
241-
return 'Access Denied'
242-
end
243-
244-
strategy = router.params['strategy'] || 'ssrf_filter'
245-
name = router.params['name'] || "Auto-generated feed for #{url}"
246-
feed_data = AutoSource.create_stable_feed(name, url, account, strategy)
247-
unless feed_data
248-
router.response.status = 500
249-
return 'Internal Server Error'
250-
end
251-
252-
router.response['Content-Type'] = 'application/json'
253-
JSON.generate(feed_data)
254-
end
255-
256-
def handle_legacy_feed(router, encoded_url)
257-
account = Auth.authenticate(router)
258-
return error_response(router, 401, 'Unauthorized') unless account
259-
return error_response(router, 403, 'Access Denied') unless AutoSource.allowed_origin?(router)
260-
261-
decoded_url = Base64.urlsafe_decode64(encoded_url)
262-
return error_response(router, 400, 'Invalid URL') unless decoded_url && Auth.valid_url?(decoded_url)
263-
return error_response(router, 403, 'Access Denied') unless AutoSource.url_allowed_for_token?(account,
264-
decoded_url)
265-
266-
generate_rss_response(router, decoded_url)
267-
rescue ArgumentError
268-
error_response(router, 400, 'Invalid encoded URL')
269-
end
270-
271-
def handle_feed_with_auth(router, feed_token = nil)
272-
url = router.params['url']
273-
return error_response(router, 400, 'url parameter required') unless url && Auth.valid_url?(url)
274-
275-
if feed_token
276-
return error_response(router, 403, 'Access Denied') unless Auth.feed_url_allowed?(feed_token, url)
277-
else
278-
account = Auth.authenticate(router)
279-
return error_response(router, 401, 'Unauthorized') unless account
280-
return error_response(router, 403, 'Access Denied') unless Auth.url_allowed?(account, url)
281-
end
282-
generate_rss_response(router, url)
283-
end
284-
285224
def generate_rss_response(router, url)
286225
router.response['Content-Type'] = 'application/xml'
287226
HttpCache.expires(router.response, 600, cache_control: 'public')

app/api/v1/feeds.rb

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
require_relative '../../feeds'
66
require_relative '../../xml_builder'
77
require_relative '../../exceptions'
8+
require_relative '../../feed_token'
89

910
module Html2rss
1011
module Web
1112
module Api
1213
module V1
13-
##
14-
# RESTful API v1 for feeds resource
15-
# Handles CRUD operations for RSS feeds
14+
# RESTful API v1 for feeds
1615
module Feeds
1716
module_function
1817

@@ -31,39 +30,19 @@ def index(_request)
3130
{ success: true, data: { feeds: feeds }, meta: { total: feeds.count } }
3231
end
3332

34-
def show(request, feed_id)
35-
if json_request?(request)
36-
show_feed_metadata(feed_id)
37-
else
38-
generate_feed_content(request, feed_id)
39-
end
33+
def show(request, token)
34+
handle_token_based_feed(request, token)
4035
end
4136

4237
def create(request)
43-
account = Auth.authenticate(request)
44-
raise UnauthorizedError, 'Authentication required' unless account
45-
46-
url = request.params['url']
47-
name = request.params['name'] || extract_site_title(url)
48-
strategy = request.params['strategy'] || 'ssrf_filter'
38+
account = authenticate_request(request)
39+
params = extract_create_params(request)
40+
validate_create_params(params, account)
4941

50-
raise BadRequestError, 'URL parameter is required' if url.nil? || url.empty?
51-
raise BadRequestError, 'Invalid URL format' unless Auth.valid_url?(url)
52-
raise ForbiddenError, 'URL not allowed for this account' unless Auth.url_allowed?(account, url)
53-
54-
feed_data = AutoSource.create_stable_feed(name, url, account, strategy)
42+
feed_data = AutoSource.create_stable_feed(params[:name], params[:url], account, params[:strategy])
5543
raise InternalServerError, 'Failed to create feed' unless feed_data
5644

57-
request.response['Content-Type'] = 'application/json'
58-
{ success: true, data: { feed: {
59-
id: feed_data[:id],
60-
name: feed_data[:name],
61-
url: feed_data[:url],
62-
strategy: feed_data[:strategy],
63-
public_url: feed_data[:public_url],
64-
created_at: Time.now.iso8601,
65-
updated_at: Time.now.iso8601
66-
} }, meta: { created: true } }
45+
build_create_response(request, feed_data)
6746
end
6847

6948
def json_request?(request)
@@ -91,17 +70,88 @@ def generate_feed_content(request, feed_id)
9170
config = LocalConfig.find(feed_id)
9271
ttl = config&.dig(:channel, :ttl) || 3600
9372

94-
# Set appropriate headers for XML response
9573
request.response['Content-Type'] = 'application/xml'
9674
request.response['Cache-Control'] = "public, max-age=#{ttl}"
9775

98-
# Convert RSS object to string
9976
rss_content.to_s
10077
end
10178

79+
def handle_token_based_feed(request, token)
80+
feed_token = validate_feed_token(token)
81+
account = get_account_for_token(feed_token)
82+
validate_account_access(account, feed_token.url)
83+
84+
generate_feed_response(request, feed_token.url)
85+
end
86+
10287
def extract_site_title(url)
10388
AutoSource.extract_site_title(url)
10489
end
90+
91+
def validate_feed_token(token)
92+
feed_token = FeedToken.validate_and_decode(token, nil, Auth.secret_key)
93+
raise UnauthorizedError, 'Invalid token' unless feed_token
94+
95+
feed_token
96+
end
97+
98+
def get_account_for_token(feed_token)
99+
account = Auth.get_account_by_username(feed_token.username)
100+
raise UnauthorizedError, 'Account not found' unless account
101+
102+
account
103+
end
104+
105+
def validate_account_access(account, url)
106+
raise ForbiddenError, 'Access Denied' unless Auth.url_allowed?(account, url)
107+
end
108+
109+
def generate_feed_response(request, url)
110+
strategy = request.params['strategy'] || 'ssrf_filter'
111+
rss_content = AutoSource.generate_feed_content(url, strategy)
112+
113+
request.response['Content-Type'] = 'application/xml'
114+
HttpCache.expires(request.response, 600, cache_control: 'public')
115+
116+
rss_content.to_s
117+
end
118+
119+
def authenticate_request(request)
120+
account = Auth.authenticate(request)
121+
raise UnauthorizedError, 'Authentication required' unless account
122+
123+
account
124+
end
125+
126+
private
127+
128+
def extract_create_params(request)
129+
url = request.params['url']
130+
{
131+
url: url,
132+
name: request.params['name'] || extract_site_title(url),
133+
strategy: request.params['strategy'] || 'ssrf_filter'
134+
}
135+
end
136+
137+
def validate_create_params(params, account)
138+
raise BadRequestError, 'URL parameter is required' if params[:url].nil? || params[:url].empty?
139+
raise BadRequestError, 'Invalid URL format' unless Auth.valid_url?(params[:url])
140+
raise ForbiddenError, 'URL not allowed for this account' unless Auth.url_allowed?(account, params[:url])
141+
end
142+
143+
def build_create_response(request, feed_data)
144+
request.response['Content-Type'] = 'application/json'
145+
{ success: true, data: { feed: {
146+
id: feed_data[:id],
147+
name: feed_data[:name],
148+
url: feed_data[:url],
149+
strategy: feed_data[:strategy],
150+
public_url: feed_data[:public_url],
151+
created_at: Time.now.iso8601,
152+
updated_at: Time.now.iso8601
153+
} }, meta: { created: true } }
154+
end
105155
end
106156
end
107157
end

0 commit comments

Comments
 (0)