07a9784d1c
* 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>
170 lines
6.0 KiB
Go
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
|
|
}
|