Skip to content

Commit 851665e

Browse files
committed
.
1 parent 65a4139 commit 851665e

23 files changed

+1795
-258
lines changed

CONFIGURATION.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414

1515
### Health Check Configuration
1616

17-
| Variable | Description | Default | Example |
18-
| ----------------------- | --------------------- | -------------- | ---------- |
19-
| `HEALTH_CHECK_USERNAME` | Health check username | Auto-generated | `health` |
20-
| `HEALTH_CHECK_PASSWORD` | Health check password | Auto-generated | `changeme` |
17+
| Variable | Description | Default | Example |
18+
| -------------------- | ------------------ | ----------- | --------------------------- |
19+
| `HEALTH_CHECK_TOKEN` | Health check token | From config | `health-check-token-xyz789` |
20+
21+
**Note:** Health check authentication now uses Bearer token authentication instead of username/password. The token is configured in `config/feeds.yml` under the `health-check` account.
2122

2223
### Ruby Integration
2324

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ ENV PORT=3000 \
4545
EXPOSE $PORT
4646

4747
HEALTHCHECK --interval=30m --timeout=60s --start-period=5s \
48-
CMD curl -f http://${HEALTH_CHECK_USERNAME}:${HEALTH_CHECK_PASSWORD}@localhost:${PORT}/health_check.txt || exit 1
48+
CMD curl -f -H "Authorization: Bearer ${HEALTH_CHECK_TOKEN}" http://localhost:${PORT}/health_check.txt || exit 1
4949

5050
ARG USER=html2rss
5151
ARG UID=991

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ gem 'html2rss-configs', github: 'html2rss/html2rss-configs'
1212
# gem 'html2rss', path: '../html2rss'
1313
# gem 'html2rss-configs', path: '../html2rss-configs'
1414

15-
gem 'base64'
1615
gem 'parallel'
1716
gem 'rack-attack'
1817
gem 'rack-cache'

Gemfile.lock

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ PLATFORMS
236236
x86_64-linux-musl
237237

238238
DEPENDENCIES
239-
base64
240239
byebug
241240
climate_control
242241
html2rss!

README.md

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,31 +39,72 @@ The application can be configured using environment variables. See the [configur
3939
- **XML Sanitization**: Prevents XML injection attacks in RSS output
4040
- **CSP Headers**: Content Security Policy headers prevent XSS attacks
4141

42+
## RESTful API
43+
44+
The application now provides a modern RESTful API v1 that follows industry standards:
45+
46+
- **Resource-based URLs**: `/api/v1/feeds`, `/api/v1/strategies`
47+
- **HTTP methods**: GET, POST, PUT, DELETE
48+
- **Proper status codes**: 200, 201, 400, 401, 403, 404, 500
49+
- **JSON responses**: Consistent response format
50+
- **Content negotiation**: XML for RSS feeds, JSON for metadata
51+
- **OpenAPI documentation**: Complete API specification
52+
53+
### Quick Start
54+
55+
```bash
56+
# List available feeds
57+
curl "https://your-domain.com/api/v1/feeds"
58+
59+
# Create a new feed
60+
curl -X POST "https://your-domain.com/api/v1/feeds" \
61+
-H "Authorization: Bearer your-token" \
62+
-H "Content-Type: application/json" \
63+
-d '{"url": "https://example.com", "name": "Example Feed"}'
64+
65+
# Get feed metadata
66+
curl "https://your-domain.com/api/v1/feeds/example"
67+
68+
# Get RSS content
69+
curl -H "Accept: application/xml" "https://your-domain.com/api/v1/feeds/example"
70+
```
71+
72+
### Documentation
73+
74+
- [RESTful API v1 Documentation](docs/api/v1/README.md)
75+
- [OpenAPI Specification](docs/api/v1/openapi.yaml)
76+
4277
## Public Feed Access
4378

44-
The application now supports secure public access to RSS feeds without requiring authentication headers. This is perfect for sharing feeds with RSS readers and other applications.
79+
The application supports secure public access to RSS feeds without requiring authentication headers. This is perfect for sharing feeds with RSS readers and other applications.
4580

4681
### How It Works
4782

48-
1. **Create a Feed**: Use the auto source feature to generate a feed
83+
1. **Create a Feed**: Use the RESTful API or auto source feature to generate a feed
4984
2. **Get Public URL**: The system returns a public URL with an embedded token
5085
3. **Share the URL**: Anyone can access the feed using this URL
5186
4. **Secure Access**: The token is cryptographically signed and URL-bound
5287

5388
### Example
5489

5590
```bash
56-
# Create a feed
57-
curl -X POST "https://your-domain.com/auto_source/create" \
91+
# Create a feed via RESTful API
92+
curl -X POST "https://your-domain.com/api/v1/feeds" \
5893
-H "Authorization: Bearer your-token" \
59-
-d "url=https://example.com&name=Example Feed"
94+
-H "Content-Type: application/json" \
95+
-d '{"url": "https://example.com", "name": "Example Feed"}'
6096

6197
# Response includes public_url
6298
{
63-
"id": "abc123",
64-
"name": "Example Feed",
65-
"url": "https://example.com",
66-
"public_url": "/feeds/abc123?token=...&url=https%3A%2F%2Fexample.com"
99+
"success": true,
100+
"data": {
101+
"feed": {
102+
"id": "abc123",
103+
"name": "Example Feed",
104+
"url": "https://example.com",
105+
"public_url": "/feeds/abc123?token=...&url=https%3A%2F%2Fexample.com"
106+
}
107+
}
67108
}
68109

69110
# Access the feed publicly

app.rb

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
require_relative 'app/feeds'
1414
require_relative 'app/health_check'
1515
require_relative 'app/local_config'
16-
require_relative 'app/response_context'
16+
require_relative 'app/exceptions'
1717
require_relative 'app/xml_builder'
1818
require_relative 'app/security_logger'
19+
require_relative 'app/api/v1/router'
20+
require_relative 'app/api/v1/health'
1921

2022
module Html2rss
2123
module Web
@@ -41,7 +43,17 @@ def development? = self.class.development?
4143
plugin :error_handler do |error|
4244
next exception_page(error) if development?
4345

44-
response.status = 500
46+
# Map custom exceptions to HTTP status codes
47+
status = case error
48+
when UnauthorizedError then 401
49+
when BadRequestError then 400
50+
when ForbiddenError then 403
51+
when NotFoundError then 404
52+
when MethodNotAllowedError then 405
53+
else 500
54+
end
55+
56+
response.status = status
4557
response['Content-Type'] = 'application/xml'
4658
require_relative 'app/xml_builder'
4759
XmlBuilder.build_error_feed(message: error.message)
@@ -54,7 +66,12 @@ def development? = self.class.development?
5466
route do |r|
5567
r.public
5668

57-
# API routes
69+
# RESTful API v1 routes (must come before legacy routes)
70+
r.on 'api', 'v1' do
71+
Api::V1::Router.route(r)
72+
end
73+
74+
# Legacy API routes (backward compatibility)
5875
r.on 'api' do
5976
r.response['Content-Type'] = 'application/json'
6077

@@ -68,7 +85,11 @@ def development? = self.class.development?
6885
JSON.generate(list_available_strategies)
6986
end
7087

88+
# Only match legacy feed names (not v1 paths)
7189
r.get String do |feed_name|
90+
# Skip if this looks like a v1 path
91+
next if feed_name.start_with?('v1/')
92+
7293
handle_feed_generation(r, feed_name)
7394
end
7495
end
@@ -276,19 +297,28 @@ def handle_authenticated_feed(router)
276297
end
277298

278299
def handle_health_check(router)
279-
health_check_account = HealthCheck.find_health_check_account
280-
account = Auth.authenticate(router)
300+
# Delegate to V1 API health endpoint to eliminate duplication
301+
302+
health_response = Api::V1::Health.show(router)
281303

282-
if account && health_check_account && account[:token] == health_check_account[:token]
304+
if health_response[:success] && health_response.dig(:data, :health, :status) == 'healthy'
283305
router.response['Content-Type'] = 'text/plain'
284-
HealthCheck.run
306+
'success'
285307
else
286-
router.response.status = 401
287-
router.response['WWW-Authenticate'] = 'Bearer realm="Health Check"'
288-
router.response['Content-Type'] = 'application/xml'
289-
require_relative 'app/xml_builder'
290-
XmlBuilder.build_error_feed(message: 'Unauthorized', title: 'Health Check Unauthorized')
308+
router.response.status = 500
309+
router.response['Content-Type'] = 'text/plain'
310+
'health check failed'
291311
end
312+
rescue UnauthorizedError
313+
router.response.status = 401
314+
router.response['WWW-Authenticate'] = 'Bearer realm="Health Check"'
315+
router.response['Content-Type'] = 'application/xml'
316+
require_relative 'app/xml_builder'
317+
XmlBuilder.build_error_feed(message: 'Unauthorized', title: 'Health Check Unauthorized')
318+
rescue StandardError => error
319+
router.response.status = 500
320+
router.response['Content-Type'] = 'text/plain'
321+
"health check error: #{error.message}"
292322
end
293323
end
294324
end

app/api/v1/feeds.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_relative '../../auth'
4+
require_relative '../../auto_source'
5+
require_relative '../../feeds'
6+
require_relative '../../xml_builder'
7+
require_relative '../../exceptions'
8+
require_relative '../../../helpers/api_response_helpers'
9+
10+
module Html2rss
11+
module Web
12+
module Api
13+
module V1
14+
##
15+
# RESTful API v1 for feeds resource
16+
# Handles CRUD operations for RSS feeds
17+
module Feeds
18+
module_function
19+
20+
##
21+
# List all available feeds
22+
# GET /api/v1/feeds
23+
# @param request [Roda::Request] request object
24+
# @return [Hash] JSON response with feeds list
25+
def index(request)
26+
feeds = Html2rss::Web::Feeds.list_feeds.map do |feed|
27+
{
28+
id: feed[:name],
29+
name: feed[:name],
30+
description: feed[:description],
31+
url: "/api/v1/feeds/#{feed[:name]}",
32+
created_at: nil, # Static feeds don't have creation time
33+
updated_at: nil
34+
}
35+
end
36+
37+
ApiResponseHelpers.success_response(
38+
{ feeds: feeds },
39+
{ total: feeds.count }
40+
)
41+
end
42+
43+
##
44+
# Get a specific feed
45+
# GET /api/v1/feeds/{id}
46+
# @param request [Roda::Request] request object
47+
# @param feed_id [String] feed identifier
48+
# @return [Hash] JSON response with feed data or XML feed content
49+
def show(request, feed_id)
50+
# Check if client wants JSON metadata or XML feed content
51+
if json_request?(request)
52+
show_feed_metadata(feed_id)
53+
else
54+
generate_feed_content(request, feed_id)
55+
end
56+
end
57+
58+
##
59+
# Create a new feed from URL
60+
# POST /api/v1/feeds
61+
# @param request [Roda::Request] request object
62+
# @return [Hash] JSON response with created feed data
63+
def create(request)
64+
account = Auth.authenticate(request)
65+
raise UnauthorizedError, 'Authentication required' unless account
66+
67+
url = request.params['url']
68+
name = request.params['name'] || extract_site_title(url)
69+
strategy = request.params['strategy'] || 'ssrf_filter'
70+
71+
raise BadRequestError, 'URL parameter is required' unless url
72+
raise BadRequestError, 'Invalid URL format' unless Auth.valid_url?(url)
73+
raise ForbiddenError, 'URL not allowed for this account' unless Auth.url_allowed?(account, url)
74+
75+
feed_data = AutoSource.create_stable_feed(name, url, account, strategy)
76+
raise InternalServerError, 'Failed to create feed' unless feed_data
77+
78+
ApiResponseHelpers.success_response({
79+
feed: {
80+
id: feed_data[:id],
81+
name: feed_data[:name],
82+
url: feed_data[:url],
83+
strategy: feed_data[:strategy],
84+
public_url: feed_data[:public_url],
85+
created_at: Time.now.iso8601,
86+
updated_at: Time.now.iso8601
87+
}
88+
}, { created: true })
89+
end
90+
91+
def json_request?(request)
92+
accept_header = request.env['HTTP_ACCEPT'] || ''
93+
accept_header.include?('application/json') && !accept_header.include?('application/xml')
94+
end
95+
96+
def show_feed_metadata(feed_id)
97+
config = LocalConfig.find(feed_id)
98+
raise NotFoundError, 'Feed not found' unless config
99+
100+
ApiResponseHelpers.success_response({
101+
feed: {
102+
id: feed_id,
103+
name: feed_id,
104+
description: "RSS feed for #{feed_id}",
105+
url: "/api/v1/feeds/#{feed_id}",
106+
strategy: config[:strategy] || 'ssrf_filter',
107+
created_at: nil,
108+
updated_at: nil
109+
}
110+
})
111+
end
112+
113+
def generate_feed_content(request, feed_id)
114+
rss_content = Html2rss::Web::Feeds.generate_feed(feed_id, request.params)
115+
config = LocalConfig.find(feed_id)
116+
ttl = config&.dig(:channel, :ttl) || 3600
117+
118+
# Set appropriate headers for XML response
119+
request.response['Content-Type'] = 'application/xml'
120+
request.response['Cache-Control'] = "public, max-age=#{ttl}"
121+
122+
# Convert RSS object to string
123+
rss_content.to_s
124+
end
125+
126+
def extract_site_title(url)
127+
AutoSource.extract_site_title(url)
128+
end
129+
130+
private
131+
end
132+
end
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)