Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
# Ignore rack cache
/tmp/rack-cache-*


# Ignore frontend build output and tooling caches
/frontend/dist/
/frontend/node_modules/
Expand All @@ -54,3 +53,4 @@

.yardoc
frontend/.astro
.pnpm-store
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ gem 'parallel'
gem 'rack-cache'
gem 'rack-timeout'
gem 'roda'
gem 'ssrf_filter'
gem 'zeitwerk'
Comment thread
gildesmarais marked this conversation as resolved.

gem 'puma', require: false
Expand Down
3 changes: 0 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,6 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
ssrf_filter (1.4.0)
stackprof (0.2.28)
stringio (3.2.0)
thor (1.5.0)
Expand Down Expand Up @@ -394,7 +393,6 @@ DEPENDENCIES
ruby-lsp
sentry-ruby
simplecov
ssrf_filter
stackprof
vcr
webmock
Expand Down Expand Up @@ -526,7 +524,6 @@ CHECKSUMS
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
ssrf_filter (1.4.0) sha256=64634f7955836808244d9ce65800af682803000b8eb15a5d573df25ab3c5422b
stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
Expand Down
41 changes: 35 additions & 6 deletions app/web/feeds/responder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,9 @@ class << self
# @param identifier [String]
# @return [String] serialized feed body.
def call(request:, target_kind:, identifier:)
feed_request = Request.call(request:, target_kind:, identifier:)
resolved_source = SourceResolver.call(feed_request)
result = Service.call(resolved_source)
normalized_identifier = feed_request.feed_name || identifier
feed_request, resolved_source, result = resolve_request(request:, target_kind:, identifier:)
body = write_response(response: request.response, representation: feed_request.representation, result:)

emit_result(target_kind:, identifier: normalized_identifier, resolved_source:, result:)
emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:)
body
rescue StandardError => error
emit_failure(target_kind:, identifier:, error:)
Expand All @@ -27,6 +23,39 @@ def call(request:, target_kind:, identifier:)

private

# @param request [Rack::Request]
# @param target_kind [Symbol]
# @param identifier [String]
# @return [Array<(Html2rss::Web::Feeds::Contracts::Request, Html2rss::Web::Feeds::Contracts::ResolvedSource, Html2rss::Web::Feeds::Contracts::RenderResult)>]
def resolve_request(request:, target_kind:, identifier:)
feed_request = Request.call(request:, target_kind:, identifier:)
resolved_source = SourceResolver.call(feed_request)
result = Service.call(resolved_source)
[feed_request, resolved_source, result]
end

# @param feed_request [Html2rss::Web::Feeds::Contracts::Request]
# @param identifier [String]
# @return [String]
def normalized_identifier(feed_request, identifier)
feed_request.feed_name || identifier
end

# @param target_kind [Symbol]
# @param identifier [String]
# @param feed_request [Html2rss::Web::Feeds::Contracts::Request]
# @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource]
# @param result [Html2rss::Web::Feeds::Contracts::RenderResult]
# @return [void]
def emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:)
emit_result(
target_kind:,
identifier: normalized_identifier(feed_request, identifier),
resolved_source:,
result:
)
end

# @param response [Rack::Response]
# @param representation [Symbol]
# @param result [Html2rss::Web::Feeds::Contracts::RenderResult]
Expand Down
15 changes: 9 additions & 6 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,23 @@ Running the app directly on the host is not supported.
| ------------------------------ | ---------------------------------------------------------- |
| `make setup` | Install Ruby and Node dependencies. |
| `make dev` | Run Ruby (port 4000) and frontend (port 4001) dev servers. |
| `make ready` | Full pre-flight check (Lint + Test + OpenAPI + Zeitwerk). |
| `make ready` | Pre-commit gate: `make quick-check` + `bundle exec rspec`. |
| `make test` | Run Ruby and frontend test suites. |
| `make lint` | Run all linters. |
| `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. |
| `make openapi` | Regenerate `public/openapi.yaml` from request specs. |
| `make openapi-verify` | Verify generated OpenAPI and frontend client artifacts are current. |
| `make openapi-lint` | Lint OpenAPI with Redocly + Spectral. |

### Frontend npm Scripts
### Frontend pnpm Scripts

| Command | Purpose |
| ----------------------- | -------------------------------------------- |
| `npm run dev` | Vite dev server with hot reload (port 4001). |
| `npm run build` | Build static assets into `frontend/dist/`. |
| `npm run test:run` | Unit tests (Vitest). |
| `npm run test:contract` | Contract tests with MSW. |
| `pnpm run dev` | Vite dev server with hot reload (port 4001). |
| `pnpm run build` | Build static assets into `frontend/dist/`. |
| `pnpm run lint` | Run ESLint across the frontend workspace. |
| `pnpm run test:run` | Unit tests (Vitest). |
| `pnpm run test:contract`| Contract tests with MSW. |

---

Expand Down
16 changes: 11 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,21 @@ flowchart TD

Requests enter via `app.rb` and are dispatched to `app/web/routes/`.

- **API v1**: Authenticated via `app/web/security/auth.rb`.
- **Public Feeds**: Validated via HMAC tokens in `app/web/security/feed_token.rb`.
- **Static feed pages (`/<feed_name>`)**: Routed by `app/web/routes/feed_pages.rb` and resolved as `target_kind: :static`.
- Source: static config in `config/feeds.yml` (via `LocalConfig.find`).
- Auth boundary: no feed token required on this route.
- Failure mode: unknown feed names fail at static config lookup.
- **Token-backed feed reads (`/api/v1/feeds/:token`)**: Routed by `app/web/routes/api_v1/feed_routes.rb` and resolved as `target_kind: :token`.
- Token scope: `FeedAccess.authorize_feed_token!` validates signature/expiry and re-checks account URL access.
- Constraint: disabled when AutoSource is off (`ForbiddenError` from `SourceResolver.ensure_auto_source_enabled!`).
- **Feed creation (`POST /api/v1/feeds`)**: Authenticated via bearer token in `app/web/security/auth.rb`; this endpoint mints feed tokens for subsequent token-backed reads.

### 2. Resolution

The `Html2rss::Web::Feeds::SourceResolver` determines where the feed configuration comes from:
The `Html2rss::Web::Feeds::SourceResolver` determines where feed configuration comes from based on route target:

- **Static**: Pre-defined in `config/feeds.yml`.
- **Dynamic**: Generated on-the-fly via the `/api/v1/feeds` endpoint (AutoSource).
- **Static (`target_kind: :static`)**: Pre-defined in `config/feeds.yml`.
- **Token (`target_kind: :token`)**: Generated from validated feed token payload + AutoSource globals.

### 3. Fetching & Rendering

Expand Down
10 changes: 10 additions & 0 deletions public/feed-reader-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
document.addEventListener('DOMContentLoaded', function () {
var readerLink = document.querySelector('[data-feed-reader-link]');
if (!readerLink) return;
var currentHref = readerLink.getAttribute('href');
if (currentHref && currentHref !== '#') return;

var pageHref = window.location.href;
var feedHref = pageHref.indexOf('feed:') === 0 ? pageHref : 'feed:' + pageHref;
readerLink.setAttribute('href', feedHref);
});
Loading
Loading