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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user