Phase C: proxy endpoint for custom-api widgets#19
Merged
Conversation
Backend-only. Adds GET /api/proxy?widget_id=... for the Phase-D custom-api widget to fetch upstream JSON without exposing URLs or secrets to the browser. No frontend widget wired up yet. - config.CustomAPIConfig + ParseCustomAPI: typed extraction from the Widget.Config map. Validates URL via httpclient (https-only + no private IPs), parses Refresh as time.Duration with a 10s floor to prevent a config typo from turning into a DoS, accepts optional Headers map for Authorization/API-key forwarding. - config.Load walks every widget and calls validateWidget — custom-api configs must be valid at boot. Unknown widget types pass through unchanged (matches existing loose contract). - config.SlugifyPageName + config.WidgetID extracted as shared helpers so the API layer (configPagesHandler) and the proxy handler build identical IDs. Previously the slugify + ID algorithm lived inside configPagesHandler; any drift would silently break proxy lookups. - handlers.ProxyCustomAPI: prebuilds a widget-ID → config map at construction time, fetches through an SSRF-hardened http.Client, caches by widget ID with per-widget TTL (absorbs tab-bursts and React Query refetch cycles), caps upstream body at 1 MiB, and passes the upstream Content-Type back untouched. - Mount under the existing JWT-protected group in router.go. Tests: 8 handler tests (missing widget_id, unknown widget, passthrough, cache hit, cache expiry, header forwarding, upstream 5xx → 502, body size cap) + 7 config tests (happy path, default refresh, min-refresh rejection, private-IP rejection, http:// rejection, url-required, WidgetID stability against the API format).
This was referenced Apr 21, 2026
Owner
Author
|
Caught during the smoke checkpoint: GET through to the SPA catch-all and returns index.html (711 bytes) instead of the upstream body. The PR description says router.go — mounts /api/proxy under auth, but the diff on that file only contains the slugify/WidgetID refactor — the actual r.Get("/api/proxy", …) line was never added. Repro (on feat/phase-c-proxy, with a custom-api widget added to config.yml): The handler-level tests in proxy_test.go pass because they call the handler directly; they don't exercise the router, so the missing mount slipped through. Suggest adding a one-line route registration inside the auth group plus a router-level regression test that asserts /api/proxy returns 401 (not 200 HTML) without a token. |
The ProxyCustomAPI handler existed but was never registered, so requests fell through to the SPA catch-all and returned index.html instead of upstream JSON. Handler-level tests didn't catch it because they call the handler directly. Adds a router-level regression test asserting /api/proxy returns 401 without a token (not 200 HTML).
Owner
Author
tested and verified. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GET /api/proxy?widget_id=...for Phase-D custom-api widgets to fetch upstream JSON without exposing URLs or secrets to the browser.httpclientfrom Phase A. URLs and headers stay server-side.refresh(min 10s, default 5m); 1 MiB response cap; upstream content-type is passed through.SlugifyPageNameandWidgetIDinto theconfigpackage so/api/config/pagesand the proxy share a single ID source of truth (drift-proof).Changes
internal/config/config.go—CustomAPIConfig,ParseCustomAPI,WidgetID, boot-time validation for custom-api widgetsinternal/api/handlers/proxy.go—ProxyCustomAPIhandler + test seam (newProxyStateacceptshttpclient.Optionssohttptest-bound upstreams work)internal/api/router.go— mounts/api/proxyunder auth; uses sharedconfig.WidgetID/config.SlugifyPageNameinternal/config/config_test.go— 7 tests (happy path, default refresh, min-refresh floor, private-IP reject, http reject, url-required, WidgetID stability)internal/api/handlers/proxy_test.go— 8 tests (missing/unknown widget, passthrough, cache hit, cache expiry, header forwarding, upstream 5xx → 502, body size cap)Test plan
go test ./... -race -count=1— greengo vet ./...— cleango build -o helm ./cmd/helm && ./helm config.ymlboots;/healthz→ 200custom-apiwidget toconfig.ymlpointing at a known JSON endpoint, hit/api/proxy?widget_id=<id>with a valid JWT, confirm passthrough + cachecustom-apiwidget withurl: http://127.0.0.1:22— startup fails fast with the SSRF validator errorScope
Backend only. No frontend widget yet — that ships in Phase D.