feat(gnoweb): change built-in playground and run views into features#5774
feat(gnoweb): change built-in playground and run views into features#5774jeronimoalbi wants to merge 14 commits into
Conversation
🛠 PR Checks SummaryAll Automated Checks passed. ✅ Manual Checks (for Reviewers):
Read More🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers. ✅ Automated Checks (for Contributors):No automated checks match this pull request. ☑️ Contributor Actions:
☑️ Reviewer Actions:
📚 Resources:Debug
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| case gnourl.IsRealm(), gnourl.IsPure(), gnourl.IsUser(), gnourl.IsPackageFork(): | ||
| return h.GetPackageView(ctx, gnourl, indexData) |
There was a problem hiding this comment.
The IsPackageFork() check is redundant, forking is done within realms or pure packages so switch already matches. The GetPackageView() method already checks if web query arguments indicate that it's a fork and if so it renders the playground in fork mode.
| // IsPackageFork checks if the URL represents a package fork. | ||
| func (gnoURL GnoURL) IsPackageFork() bool { | ||
| return gnoURL.WebQuery.Has("fork") | ||
| } |
There was a problem hiding this comment.
Removed because is not being used right now
alexiscolin
left a comment
There was a problem hiding this comment.
Solid refactor. The per-feature layout is clean, decoupling each feature from the parent gnoweb via a locally-declared ClientAdapter is the right move for testability, and the Makefile tricks (vpath, dup-basename guard) are nice. Thanks also for the inline notes on IsPackageFork: that clears up my two switch / url.go questions before I had to ask them.
Two things I'd want pinned down before merge I suppose:
GetForkViewno longer returns an error page whenListFilesfails: it falls back to the default playground while still stampingForkFrom, so the page will read "forked from X" with the default snippet inside. That feels like an undocumented UX change; flagging it inline.- Codecov shows
feature/run/{handler.go, component.go, view.go}at 0%. One small test would close all three.
The rest is mostly small stuff plus a handful of cheap security wins (decompression-bomb cap, MaxBytesReader on eval, X-Forwarded-For trust assumption, missing rate-limit on /funcs). None are blockers: just feels worth landing them while we're already in this code.
| Renderer: cfg.Renderer, | ||
| Aliases: cfg.Aliases, | ||
| Logger: logger, | ||
| Playground: playground.New(playground.Deps{ |
There was a problem hiding this comment.
- The featureClientAdapter / playground.ClientAdapter link is only validated at the playground.New(...) call site today, which is easy to miss when iterating on either interface. A static assertion next to the type would fail-fast on drift: var _ playground.ClientAdapter = (*featureClientAdapter)(nil) no?
- Doc says "for custom features" (plural) but only playground actually uses it: run declares no client. Probably worth tightening to "for the playground feature, drops the FileMeta return" unless you plan to share it with future features.
There was a problem hiding this comment.
Changed in ca89afc
Changed client adapter name to make sense for current code. The previous name was early optimization in case other feature like block explorer requires the same adapter.
| // New validates required deps and returns a Handler. | ||
| func New(deps Deps) *Handler { | ||
| if deps.Client == nil { | ||
| panic("playground.New: Client is required") |
There was a problem hiding this comment.
Validation is asymmetric here no? Client panics if nil, but empty Domain / Remote / ChainId slide through silently even though they all end up rendered into the page. Not necessarily wrong (maybe empty defaults are legitimate in dev?), just felt inconsistent enough to flag. Either validate them all or drop a one-liner saying empties are intentional.
| if initial != "" { | ||
| if decoded, err := base64.StdEncoding.DecodeString(initial); err == nil { | ||
| // Decompress code when given as DEFLATE compressed data format (RFC 1951) | ||
| if u.Query.Has("z") { |
There was a problem hiding this comment.
- io.ReadAll(zr) on user-controlled compressed input is a textbook decompression bomb vector. A small base64 with a high compression ratio expands to arbitrary RAM. An io.LimitReader(zr, 1<<20) (or whatever the playground's reasonable code-size ceiling is) may shut that down?
- zr.Close() isn't deferred. Currently safe because the only path through the if-block reaches it, but the next edit that adds an early return inside the if will silently leak. defer zr.Close() right after flate.NewReader(...) could be a free upgrade.
| func (h *Handler) GetForkView(ctx context.Context, u *weburl.GnoURL) (int, *components.View) { | ||
| pkgPath := u.Path | ||
| files, err := h.deps.Client.ListFiles(ctx, pkgPath) | ||
| if err != nil { |
There was a problem hiding this comment.
This changed behavior on me and I'm not sure it's intentional. Old code returned GetClientErrorStatusPage here. New code falls back to the default playground but still sets ForkFrom: path.Join(h.deps.Domain, pkgPath), so the page will display "forked from gno.land/r/foo" while actually showing the default Render snippet: which seems misleading.
If the friendlier fallback is intentional, two options that feel cleaner:
- leave ForkFrom empty in this branch (silently degrade to a fresh playground), or
- add a ForkFailed field so the template can show an honest "couldn't fetch source, starting fresh" hint.
Could also be I'm reading the template wrong, let me know.
There was a problem hiding this comment.
It was provably done for simplicity in the PoC, but now it makes sense to return an error.
Done in 870cc7a
| } | ||
|
|
||
| func newRateLimiter(burstSize int, refillInterval time.Duration) *rateLimiter { | ||
| rl := &rateLimiter{ |
There was a problem hiding this comment.
pruneLoop is started here with no way to stop it. Fine in prod (one Handler per process) but every test that calls New(...) leaks a goroutine, and there's no escape hatch. Same as the old code, flagging because the new feature layout could plausibly spawn multiple Handler instances down the line. A context.Context taken by New (or a Close() method) may fix it cleanly.
| // tokens; one token is added every refillInterval. | ||
| type rateLimiter struct { | ||
| mu sync.Mutex | ||
| buckets map[string]*rateBucket |
There was a problem hiding this comment.
The bucket map grows linearly with unique IPs and is only trimmed every minute. Under a flood from distinct (or spoofed: see the XFF comment) IPs it can balloon to IPsPerSecond × 60 entries before the first prune sweep. A hard cap that evicts oldest entries above some threshold (say 100k) would put a ceiling on the blast radius. Pre-existing, so not blocking, but maybe cheap to add?
| func clientIP(r *http.Request) string { | ||
| if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | ||
| if ip, _, err := net.SplitHostPort(strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])); err == nil { | ||
| return ip | ||
| } | ||
| return strings.TrimSpace(strings.SplitN(xff, ",", 2)[0]) | ||
| } | ||
| ip, _, err := net.SplitHostPort(r.RemoteAddr) | ||
| if err != nil { | ||
| return r.RemoteAddr | ||
| } | ||
| return ip | ||
| } |
There was a problem hiding this comment.
This is the security issue I'd most like resolved before merge: X-Forwarded-For is trusted unconditionally. If gnoweb is ever deployed without a reverse proxy in front (or with one that doesn't strip incoming XFF), an attacker can rotate the spoofed IP per request and bypass rate-limiting entirely.
Two ways out I guess:
- document the deployment assumption (gnoweb requires a trusted XFF-terminating proxy), or
- make XFF trust opt-in via config, default off -> use RemoteAddr.
The second is strictly safer. The first is fine if you're confident about the deployment story: worth at least a line in a doc?
| func mustParse(name string, paths ...string) *template.Template { | ||
| t, err := template.New(name).ParseFS(templateFS, paths...) | ||
| if err != nil { | ||
| panic("playground: parse " + paths[0] + ": " + err.Error()) | ||
| } | ||
| return t | ||
| } |
There was a problem hiding this comment.
Two unrelated observations:
- The name parameter looks dead to me: template.New(name) creates the container, but ExecuteTemplate in the component looks up "renderPage" defined by {{ define "renderPage" }} inside the file, not the container's name. If I'm right, you can drop the param?
- This file and feature/run/template.go are 90% identical. At two features it's fine, but if feature/* becomes the pattern, hoisting this into a tiny shared helper (e.g. feature/internal/featuretmpl) would pay off?
There was a problem hiding this comment.
Indeed, name is no needed within the current features context.
Regarding the pattern, we do have some differences, run feature uses other templates so the parsing there have extra steps. Most features might end up being the same though.
Removed in 9682008
| @@ -1,5 +1,5 @@ | |||
| import { CodeEditor, isDarkMode } from "./code-editor.js"; | |||
| import { BaseController } from "./controller.js"; | |||
| import { CodeEditor, isDarkMode } from "../../../frontend/js/code-editor.js"; | |||
There was a problem hiding this comment.
../../../frontend/js/code-editor.js: three dot-dots. Brittle to any future move of feature/. A TS path alias ("@gnoweb/js/*" in tsconfig.json) should clean it up. I should do the same for the state explorer.
|
When the Actions page does a non-crossing query, it uses This causes problems with security filters. It can be fixed by updating to also use the new api/eval endpoint. After merging this PR, we should do that fix. |
|
Addressing review comments |
It limits the files fetched asynchronically, limits the total size of the compressed playground code and forked package size, to avoid memory exhaustion.
This alias replaces the "../../.." imports within the features. It also adds a minimal `tsconfig.json` to avoid issues with IDEs, otherwise imports will show an error.
|
@alexiscolin I think we should address the rest of the comments in different issues to make it easier to review. Most of the issues are actually not really from this PR but from previous code, from pre-existing or "new" files splitted from these. I'm thinking that this PR is deviating from just moving run and playground to features into a more complex one that solves other pre-existing issues 🤔 WDYT? |
No description provided.