fix(share): route share links through gist.githack so big tasks render (#1008)

* fix(share): route share links through gist.githack so big tasks render

gistpreview.github.io fetches gists via the public GitHub API, which returns
`content: ""` for files past GitHub's per-response content budget (~1 MB).
For non-trivial sessions, share.html and snapshot.json land past that
budget and gistpreview's `document.write(content)` renders a blank page.
gist.githack.com proxies the raw file content directly, so multi-MB
share.html renders correctly — at the cost of a one-time Cloudflare
anti-phishing interstitial on first visit, accepted as the price of
fidelity for big tasks.

displayURL now normalises bare gist URLs and unpinned githack URLs to the
canonical `gist.githack.com/<owner>/<id>/raw/share.html` form; legacy
gistpreview URLs pass through unchanged (the owner is not recoverable
from a gistpreview URL, so users can revoke + re-share to get the
githack form going forward).

* fix(share): address PR #1008 review feedback

- displayURL now re-pins legacy gistpreview URLs stored without /share.html
  so they hit the styled view instead of silently falling back to the
  alphabetically-first README.md (flagged by Greptile).
- Spec: unify two stale gistpreview references (API endpoint description
  and the publish scenario) to the canonical githack form, with a note
  that legacy gistpreview rows pass through. Flagged by CodeRabbit and
  Claude — the spec is the public contract; one stale mention undermines
  the migration.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-22 11:40:36 +01:00
committed by GitHub
parent e740417885
commit 07a9784d1c
9 changed files with 174 additions and 121 deletions
@@ -33,9 +33,9 @@ func (b *GistBackend) Name() string { return BackendGitHubGist }
// Upload marshals the snapshot to JSON, builds the rendered share.html and
// a README, then creates a secret gist with all three. The returned URL is
// the rendered view served via gistpreview.github.io — that's the link
// users share. The gist itself is preserved in the database via the
// externalID so we can still delete it on revoke.
// the rendered view served via gist.githack.com — that's the link users
// share. The gist itself is preserved in the database via the externalID
// so we can still delete it on revoke.
func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, string, error) {
body, err := json.MarshalIndent(snap, "", " ")
if err != nil {
@@ -71,66 +71,74 @@ func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, strin
}
// renderedURLForGist returns the public rendering URL we hand to the user
// for a given gist. We route through gistpreview.github.io, a static
// GitHub-Pages site that fetches the gist via the public API and renders
// an HTML file client-side. We use it instead of raw.githack.com so
// visitors don't hit the "One more step" anti-phishing interstitial.
// for a given gist. We route through gist.githack.com, a Cloudflare-backed
// CDN that proxies the gist's raw file content directly.
//
// Critically: we append "/share.html" to the URL because gistpreview's
// file-picker logic picks the first iterated file unless one is named
// "index.html". Our gists contain README.md, share.html, snapshot.json —
// without the explicit suffix, gistpreview picks README.md (alphabetical
// first), renders it raw, and the page appears unstyled. The explicit
// filename pins it to share.html.
// Why githack and not gistpreview.github.io: gistpreview fetches gists
// through the GitHub gists API, which serves `content: ""` for any file
// past GitHub's per-response content budget (~1 MB combined across all
// files). For non-trivial sessions, share.html / snapshot.json land past
// that budget, and gistpreview renders a blank page. githack proxies the
// raw file directly, so multi-megabyte share.html renders correctly.
//
// https://gist.github.com/<owner>/<id> → https://gistpreview.github.io/?<id>/share.html
// https://gist.github.com/<id> → https://gistpreview.github.io/?<id>/share.html
// Trade-off: githack shows a one-time anti-phishing interstitial the first
// time a user visits any githack URL — accepted as the price of fidelity
// for big tasks.
//
// Returns "" if the URL doesn't look like a gist URL — callers fall back
// to whatever was stored.
// We append "/raw/share.html" to pin the styled HTML view; without an
// explicit filename githack can't pick the right file.
//
// https://gist.github.com/<owner>/<id> → https://gist.githack.com/<owner>/<id>/raw/share.html
//
// Returns "" if the URL is missing the owner segment (anonymous gist) —
// callers fall back to whatever was stored.
func renderedURLForGist(gistHTMLURL string) string {
id := gistIDFromGistHTMLURL(gistHTMLURL)
if id == "" {
owner, id := ownerAndIDFromGistHTMLURL(gistHTMLURL)
if owner == "" || id == "" {
return ""
}
return gistpreviewURL(id)
return githackURL(owner, id)
}
// gistpreviewURL builds the gistpreview.github.io URL for a gist id,
// explicitly pinning the file to share.html.
func gistpreviewURL(gistID string) string {
return "https://gistpreview.github.io/?" + gistID + "/share.html"
// githackURL builds the gist.githack.com raw-render URL for a gist,
// explicitly pinning the file to share.html so the styled view loads.
func githackURL(owner, id string) string {
return "https://gist.githack.com/" + owner + "/" + id + "/raw/share.html"
}
// gistIDFromGistHTMLURL extracts the gist ID from a github.com gist URL.
// Returns "" for anything that doesn't match the expected shape.
func gistIDFromGistHTMLURL(gistHTMLURL string) string {
// ownerAndIDFromGistHTMLURL parses a github.com gist URL of the form
// `https://gist.github.com/<owner>/<id>`. Anonymous gists (no owner
// segment) return ("", ""); githack needs both to address the file.
func ownerAndIDFromGistHTMLURL(gistHTMLURL string) (string, string) {
const prefix = "https://gist.github.com/"
if !strings.HasPrefix(gistHTMLURL, prefix) {
return ""
return "", ""
}
rest := strings.Trim(strings.TrimPrefix(gistHTMLURL, prefix), "/")
if rest == "" {
return ""
return "", ""
}
// "<owner>/<id>" or "<id>" — the ID is always the last segment.
parts := strings.Split(rest, "/")
return parts[len(parts)-1]
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", ""
}
return parts[0], parts[1]
}
// gistIDFromGithackURL pulls the gist ID out of a stored raw.githack URL
// (legacy rows that were written before the gistpreview switchover).
// Returns "" for anything that doesn't match.
func gistIDFromGithackURL(url string) string {
// ownerAndIDFromGithackURL extracts (owner, id) from a stored githack URL
// of the form `https://gist.githack.com/<owner>/<id>/raw/...`. Used by
// displayURL to re-pin /raw/share.html on rows whose stored URL targets
// a different file. Returns ("", "") on anything that doesn't match.
func ownerAndIDFromGithackURL(url string) (string, string) {
const prefix = "https://gist.githack.com/"
if !strings.HasPrefix(url, prefix) {
return ""
return "", ""
}
parts := strings.Split(strings.TrimPrefix(url, prefix), "/")
if len(parts) < 2 || parts[1] == "" {
return ""
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", ""
}
return parts[1]
return parts[0], parts[1]
}
// Delete removes the gist. A 404 from GitHub is returned to the caller as-is
@@ -43,10 +43,13 @@ func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) {
t.Fatalf("expected non-empty id/url, got %q / %q", id, url)
}
// The returned URL is the rendered gistpreview.github.io viewer URL,
// NOT the raw gist URL — that's what the user shares.
if !strings.HasPrefix(url, "https://gistpreview.github.io/?") {
t.Fatalf("expected gistpreview.github.io URL, got %q", url)
// The returned URL is the gist.githack.com raw-render URL, NOT the
// raw gist URL — githack proxies the full file content so big shares
// render even when GitHub's gists API would have returned an empty
// content field.
if !strings.HasPrefix(url, "https://gist.githack.com/") ||
!strings.HasSuffix(url, "/raw/share.html") {
t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", url)
}
gists := mock.Gists()
@@ -26,14 +26,14 @@ func BuildShareHTML(snap *Snapshot) string {
b.WriteString("<!doctype html>\n<html lang=\"en\">\n")
writeHTMLHead(&b, snap)
b.WriteString("<body>\n")
// Inline the stylesheet at the top of <body> rather than in <head> so it
// survives renderers (notably gistpreview.github.io) that copy only the
// body content into their own page — dropping <head> and any <style> tags
// in it. <style> in <body> is non-conforming HTML but every browser
// applies it without complaint, and it's the standard workaround for
// gist-rendering services. We ALSO keep a <style> in head so direct
// raw-HTML viewers still get the styles via the normal path; the
// duplicate is harmless (CSS rules are idempotent).
// Duplicate the stylesheet at the top of <body> as defense-in-depth: some
// gist-rendering proxies copy only the body content into their own page,
// dropping <head> and any <style> tags it contains. gist.githack.com
// serves the raw file unchanged so <head> styles work, but we keep the
// inline copy in case we ever route through a body-only renderer (or one
// is added by a downstream embedder). <style> in <body> is non-conforming
// HTML but every browser applies it without complaint, and CSS rules are
// idempotent so the duplicate is harmless.
b.WriteString("<style>")
b.WriteString(shareCSS)
b.WriteString("</style>\n")
@@ -182,8 +182,10 @@ func TestRenderedURLForGist(t *testing.T) {
input string
want string
}{
{"authenticated_gist", "https://gist.github.com/jane/abc123", "https://gistpreview.github.io/?abc123/share.html"},
{"anonymous_gist", "https://gist.github.com/abc123", "https://gistpreview.github.io/?abc123/share.html"},
{"authenticated_gist", "https://gist.github.com/jane/abc123", "https://gist.githack.com/jane/abc123/raw/share.html"},
// Anonymous gists lack the owner segment githack needs to address the
// file; we return "" so callers fall back to whatever was stored.
{"anonymous_gist", "https://gist.github.com/abc123", ""},
{"wrong_host", "https://example.com/jane/abc123", ""},
{"empty", "", ""},
}
@@ -197,23 +199,25 @@ func TestRenderedURLForGist(t *testing.T) {
}
}
func TestGistIDFromGithackURL(t *testing.T) {
func TestOwnerAndIDFromGithackURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want string
name string
input string
wantOwner string
wantID string
}{
{"valid_legacy_url", "https://gist.githack.com/jane/abc123/raw/share.html", "abc123"},
{"wrong_host", "https://example.com/jane/abc123/raw/share.html", ""},
{"too_short", "https://gist.githack.com/jane", ""},
{"empty", "", ""},
{"valid_url", "https://gist.githack.com/jane/abc123/raw/share.html", "jane", "abc123"},
{"wrong_host", "https://example.com/jane/abc123/raw/share.html", "", ""},
{"too_short", "https://gist.githack.com/jane", "", ""},
{"empty", "", "", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := gistIDFromGithackURL(tc.input); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
owner, id := ownerAndIDFromGithackURL(tc.input)
if owner != tc.wantOwner || id != tc.wantID {
t.Fatalf("got (%q,%q), want (%q,%q)", owner, id, tc.wantOwner, tc.wantID)
}
})
}
@@ -8,11 +8,12 @@ import (
)
// BuildGistREADME renders the README that appears at the top of the gist
// page on github.com. The primary user-facing rendering is share.html (served
// via raw.githack.com); the README is mostly a "go to the pretty view"
// pointer plus a markdown fallback for anyone who lands on the gist directly.
// renderedURL is the raw.githack.com link to share.html; pass "" if it's not
// known yet (the README is regenerated post-upload with the real link).
// page on github.com. The primary user-facing rendering is share.html
// (served via gist.githack.com); the README is mostly a "go to the pretty
// view" pointer plus a markdown fallback for anyone who lands on the gist
// directly. renderedURL is the gist.githack.com link to share.html; pass
// "" if it's not known yet (the README is regenerated post-upload with
// the real link).
func BuildGistREADME(snap *Snapshot, renderedURL string) string {
if snap == nil {
return "# kandev share\n"
@@ -45,7 +45,7 @@ func TestBuildGistREADME_FullConversation(t *testing.T) {
Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}},
}
md := BuildGistREADME(snap, "https://gistpreview.github.io/?mock-gist-1/share.html")
md := BuildGistREADME(snap, "https://gist.githack.com/jane/mock-gist-1/raw/share.html")
assertContains(t, md, "# Investigate flaky test")
assertContains(t, md, "<kbd>claude-acp</kbd>")
assertContains(t, md, "<kbd>claude-opus-4-7</kbd>")
@@ -76,7 +76,7 @@ func TestBuildGistREADME_NilAndEmpty(t *testing.T) {
t.Fatal("nil snapshot should still produce a non-empty README")
}
empty := &Snapshot{Task: TaskMeta{Title: "Untitled"}}
got := BuildGistREADME(empty, "https://gistpreview.github.io/?g1/share.html")
got := BuildGistREADME(empty, "https://gist.githack.com/jane/g1/raw/share.html")
if !strings.Contains(got, "_(No messages.)_") {
t.Fatalf("expected empty-messages placeholder, got: %s", got)
}
+24 -16
View File
@@ -70,36 +70,44 @@ func toShareResponse(s *Share) shareResponse {
}
// displayURL returns the URL we want clients to use when opening a share.
// We always prefer the gistpreview.github.io rendered view (the static
// renderer pulls the gist's first HTML file via the GitHub API). Three
// stored formats are normalised on the way out:
// We always prefer the gist.githack.com rendered view (it proxies the raw
// gist file directly, avoiding the GitHub gists-API content budget that
// makes gistpreview.github.io render blank for big snapshots). Stored
// formats are normalised on the way out:
//
// - bare gist URL (https://gist.github.com/<owner>/<id>) — old rows
// - bare gist URL (https://gist.github.com/<owner>/<id>) — older rows
// written before share.html landed in the gist
// - raw.githack URL (https://gist.githack.com/<owner>/<id>/raw/share.html)
// — rows written during the brief raw.githack window
// - already a gistpreview URL — passthrough
// - already a githack URL — re-pinned to /raw/share.html so rows that
// targeted a different file (or no file) land on the styled view
// - legacy gistpreview URL — passed through (owner is not recoverable
// from gistpreview shape, so we can't upgrade to githack), but
// re-pinned with /share.html when stored without a filename so
// pre-existing rows still hit the styled HTML rather than the
// alphabetically-first README.md
//
// Anything we don't recognise is returned unchanged.
func displayURL(stored string) string {
if stored == "" {
return ""
}
// Already a gistpreview URL — if the caller pinned share.html, keep it;
// otherwise re-pin it so gistpreview doesn't fall back to README.md.
// Already a githack URL — re-pin /raw/share.html.
if owner, id := ownerAndIDFromGithackURL(stored); owner != "" && id != "" {
return githackURL(owner, id)
}
// Bare gist URL → convert to githack.
if rendered := renderedURLForGist(stored); rendered != "" {
return rendered
}
// Legacy gistpreview URL — pass through, but re-pin /share.html for
// rows stored without a filename so they don't silently fall back to
// rendering README.md.
const previewPrefix = "https://gistpreview.github.io/?"
if strings.HasPrefix(stored, previewPrefix) {
id := strings.TrimPrefix(stored, previewPrefix)
if i := strings.Index(id, "/"); i >= 0 {
id = id[:i]
}
return gistpreviewURL(id)
}
if rendered := renderedURLForGist(stored); rendered != "" {
return rendered
}
if id := gistIDFromGithackURL(stored); id != "" {
return gistpreviewURL(id)
return previewPrefix + id + "/share.html"
}
return stored
}
+32 -17
View File
@@ -52,9 +52,9 @@ func newGinRouter(h *HTTPHandlers) *gin.Engine {
func TestHTTP_Create_HappyPath(t *testing.T) {
t.Parallel()
reader := completedSession()
// Real GistBackend returns the gistpreview.github.io rendered URL —
// match that shape so the test mirrors production.
backend := &mockBackend{nextID: "gist-x", nextURL: "https://gistpreview.github.io/?gist-x/share.html"}
// Real GistBackend returns the gist.githack.com rendered URL — match
// that shape so the test mirrors production.
backend := &mockBackend{nextID: "gist-x", nextURL: "https://gist.githack.com/jane/gist-x/raw/share.html"}
handlers, _ := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
@@ -69,7 +69,7 @@ func TestHTTP_Create_HappyPath(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.URL != "https://gistpreview.github.io/?gist-x/share.html" {
if body.URL != "https://gist.githack.com/jane/gist-x/raw/share.html" {
t.Fatalf("unexpected url: %s", body.URL)
}
}
@@ -201,22 +201,35 @@ func TestDisplayURL_NormalizesAllFormats(t *testing.T) {
want string
}{
{
"bare_gist_url_old_share",
"bare_gist_url_converted_to_githack",
"https://gist.github.com/jane/abc123",
"https://gistpreview.github.io/?abc123/share.html",
},
{
"legacy_githack_url_gets_rewritten",
"https://gist.githack.com/jane/abc123/raw/share.html",
"https://gistpreview.github.io/?abc123/share.html",
},
{
"already_gistpreview_with_share_html_passthrough",
"githack_url_passthrough",
"https://gist.githack.com/jane/abc123/raw/share.html",
"https://gist.githack.com/jane/abc123/raw/share.html",
},
{
"githack_without_filename_repinned",
"https://gist.githack.com/jane/abc123",
"https://gist.githack.com/jane/abc123/raw/share.html",
},
{
// Legacy gistpreview rows (written before the githack switch)
// lack the owner segment we need to build a githack URL, so
// they pass through. Users on those rows still get a working
// link for small tasks; revoking + re-sharing surfaces the
// githack URL going forward.
"legacy_gistpreview_with_share_html_passthrough",
"https://gistpreview.github.io/?abc123/share.html",
"https://gistpreview.github.io/?abc123/share.html",
},
{
"older_gistpreview_without_filename_repinned",
// Older gistpreview rows stored without /share.html still get
// re-pinned so gistpreview doesn't silently fall back to
// rendering README.md.
"legacy_gistpreview_without_filename_repinned",
"https://gistpreview.github.io/?abc123",
"https://gistpreview.github.io/?abc123/share.html",
},
@@ -248,13 +261,14 @@ func TestHTTP_List_NormalizesLegacyURLsOnDisplay(t *testing.T) {
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
// Seed two legacy rows from earlier URL formats — the API should
// surface both as gistpreview URLs.
// Seed two legacy rows from earlier URL formats — bare gist and a
// githack URL with no filename pinned. Both should surface as the
// canonical /raw/share.html githack form.
for _, row := range []*Share{
{ID: "old-1", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "abc123",
ExternalURL: "https://gist.github.com/jane/abc123"},
{ID: "old-2", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "def456",
ExternalURL: "https://gist.githack.com/jane/def456/raw/share.html"},
ExternalURL: "https://gist.githack.com/jane/def456"},
} {
if err := svc.repo.Create(context.Background(), row); err != nil {
t.Fatalf("seed %s: %v", row.ID, err)
@@ -275,8 +289,9 @@ func TestHTTP_List_NormalizesLegacyURLsOnDisplay(t *testing.T) {
t.Fatalf("expected 2 shares, got %d", len(body.Shares))
}
for _, s := range body.Shares {
if !strings.HasPrefix(s.URL, "https://gistpreview.github.io/?") {
t.Fatalf("expected gistpreview URL, got %q", s.URL)
if !strings.HasSuffix(s.URL, "/raw/share.html") ||
!strings.HasPrefix(s.URL, "https://gist.githack.com/") {
t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", s.URL)
}
}
}
+32 -18
View File
@@ -155,9 +155,10 @@ POST /api/v1/tasks/:taskId/sessions/:sessionId/shares?dry_run=true
GET /api/v1/tasks/:taskId/sessions/:sessionId/shares
-> 200 { shares: [{ id, url, created_at, revoked_at, snapshot_size_bytes }] }
Lists every share row (including revoked) for the session. The
`url` field is always normalised to the current rendering URL
(gistpreview.github.io/?<id>/share.html) regardless of what's
stored.
`url` field is always normalised to the canonical githack rendering
URL (gist.githack.com/<owner>/<id>/raw/share.html) regardless of
what's stored; legacy gistpreview.github.io rows are passed
through (re-pinned to /share.html when the filename is missing).
DELETE /api/v1/shares/:shareId
-> 204
@@ -187,23 +188,35 @@ type Backend interface {
### Share URL format
The user-facing share URL routes through **gistpreview.github.io** — a
static GitHub-Pages renderer that fetches the gist via the GitHub API and
injects the HTML client-side. We use it instead of raw.githack.com because
raw.githack shows a "One more step" anti-phishing interstitial on HTML
files served from gists.
The user-facing share URL routes through **gist.githack.com** — a
Cloudflare-backed CDN that proxies the gist's raw file content directly.
Format: `https://gistpreview.github.io/?<gist_id>/share.html`
We initially shipped via `gistpreview.github.io` (a static GitHub-Pages
renderer that fetches gists through the public GitHub API), but the gists
API returns `content: ""` for any file past GitHub's per-response content
budget (~1 MB combined across files). For non-trivial sessions, the
generated `share.html` lands past that budget, and gistpreview renders a
blank page. githack proxies the raw HTML directly, so even multi-megabyte
share.html renders correctly.
The `/share.html` suffix is required: gistpreview's file picker defaults
to the first iterated file (alphabetically `README.md` in our gists), so
without an explicit filename the page renders raw markdown instead of the
styled HTML.
The trade-off is that githack shows a one-time anti-phishing interstitial
the first time a user visits any githack URL on a given device — accepted
as the price of fidelity for big tasks.
`displayURL` on the API layer normalises every legacy URL format
(`gist.github.com/<owner>/<id>`, `gist.githack.com/<owner>/<id>/raw/share.html`,
or a gistpreview URL missing the filename) to the canonical
gistpreview-with-share.html form on every response.
Format: `https://gist.githack.com/<owner>/<id>/raw/share.html`
The `/raw/share.html` suffix is required: githack can't pick a default
file without an explicit path.
`displayURL` on the API layer normalises stored URL formats to the
canonical githack-with-share.html form on every response:
- `gist.github.com/<owner>/<id>` → githack
- `gist.githack.com/<owner>/<id>` (no filename) → re-pinned to `/raw/share.html`
- Legacy `gistpreview.github.io/?<id>/share.html` → passed through
unchanged. The owner is not recoverable from a gistpreview URL, so we
cannot auto-upgrade. Users can revoke + re-share to get a githack URL
for those rows.
## State machine
@@ -295,7 +308,8 @@ There is no "draft" or "scheduled" state; publish is synchronous.
- **GIVEN** the preview dialog is open, **WHEN** the user clicks "Publish
to GitHub Gist", **THEN** kandev uploads a secret gist containing
`snapshot.json`, `share.html`, and `README.md`, inserts a `task_shares`
row, and the dialog displays the gistpreview URL with a copy button.
row, and the dialog displays the gist.githack.com URL with a copy
button.
- **GIVEN** an active share row, **WHEN** the user clicks Revoke, **THEN**
the gist is deleted from GitHub, the row is updated with `revoked_at`,