Files
kandev/apps/backend/internal/task/share/gist_backend.go
T
João Salavisa 07a9784d1c 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>
2026-05-22 11:40:36 +01:00

170 lines
6.0 KiB
Go

package share
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/kandev/kandev/internal/github"
)
// GistMaxBytes is a conservative cap on a single snapshot.json file. GitHub
// caps individual gist files at 100 MiB but 10 MiB is plenty for any
// reasonable conversation and avoids accidentally pushing huge payloads.
const GistMaxBytes = 10 * 1024 * 1024
// GistBackend uploads snapshots to GitHub Gists on the authenticated user's
// account. Created gists are always secret (unlisted); anyone with the URL
// can view them but they are not crawled.
type GistBackend struct {
client github.Client
}
// NewGistBackend returns a Backend that uses the given github.Client.
func NewGistBackend(client github.Client) *GistBackend {
return &GistBackend{client: client}
}
// Name implements Backend.
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 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 {
return "", "", fmt.Errorf("marshal snapshot: %w", err)
}
if len(body) > GistMaxBytes {
return "", "", fmt.Errorf("%w: %d bytes > %d", ErrSnapshotTooLarge, len(body), GistMaxBytes)
}
resp, err := b.client.CreateGist(ctx, github.CreateGistInput{
Description: gistDescription(snap),
Public: false,
Files: map[string]github.GistFile{
// share.html sorts first alphabetically in the gist file list,
// which puts the rendered view at the top of the gist page too.
"share.html": {Content: BuildShareHTML(snap)},
"snapshot.json": {Content: string(body)},
// README is built with empty renderedURL — the user's primary
// link goes to the rendered view; the README is a fallback for
// folks who land on the gist directly.
"README.md": {Content: BuildGistREADME(snap, "")},
},
})
if err != nil {
return "", "", fmt.Errorf("create gist: %w", err)
}
rendered := renderedURLForGist(resp.HTMLURL)
if rendered == "" {
// Fall back to the raw gist URL if we couldn't parse a gist ID —
// not pretty, but at least the user has a working link.
rendered = resp.HTMLURL
}
return resp.ID, rendered, nil
}
// renderedURLForGist returns the public rendering URL we hand to the user
// for a given gist. We route through gist.githack.com, a Cloudflare-backed
// CDN that proxies the gist's raw file content directly.
//
// 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.
//
// 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.
//
// 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 {
owner, id := ownerAndIDFromGistHTMLURL(gistHTMLURL)
if owner == "" || id == "" {
return ""
}
return githackURL(owner, id)
}
// 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"
}
// 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 "", ""
}
rest := strings.Trim(strings.TrimPrefix(gistHTMLURL, prefix), "/")
if rest == "" {
return "", ""
}
parts := strings.Split(rest, "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", ""
}
return parts[0], parts[1]
}
// 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 "", ""
}
parts := strings.Split(strings.TrimPrefix(url, prefix), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", ""
}
return parts[0], parts[1]
}
// Delete removes the gist. A 404 from GitHub is returned to the caller as-is
// so the service can treat it as "already gone".
func (b *GistBackend) Delete(ctx context.Context, externalID string) error {
if err := b.client.DeleteGist(ctx, externalID); err != nil {
var apiErr *github.GitHubAPIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return err
}
return fmt.Errorf("delete gist %s: %w", externalID, err)
}
return nil
}
func gistDescription(snap *Snapshot) string {
if snap == nil || snap.Task.Title == "" {
return "kandev task share"
}
return "kandev share: " + snap.Task.Title
}
func nonEmpty(s, fallback string) string {
if s == "" {
return fallback
}
return s
}