Skip to content
quarry Artifact [32da9402c0]
Login

Artifact [32da9402c0]

Artifact 32da9402c0c940075dbb741ef53aaaa4c7a860e865df1c9467070f381016a9a6:


package web

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"net/http"
	"net/url"
	"strings"
	"sync"

	_ "golang.org/x/image/webp"

	"craftdesign.group/quarry/internal/idn"
	"craftdesign.group/quarry/internal/ogcard"
	"craftdesign.group/quarry/internal/ops"
)

// ogCards keeps the cards rendered lately, by everything that went into
// one: a link pasted into a busy channel is unfurled by every client in
// it, and the card has not changed between them. Cleared, not evicted,
// when full: a forge has few repositories, and a card is milliseconds.
var ogCards = struct {
	sync.Mutex
	m map[string][]byte
}{m: map[string][]byte{}}

const ogCardKeep = 256

// serveOGCard answers /r/{repo}/og.png, the repository's link-preview
// image (internal/ogcard). It reads what the About card and the stats
// strip read, through the same ops, so the preview and the page say the
// same thing. The read gate and the takedown gate in front of /r/ have
// already answered for a repository this reader may not see.
func serveOGCard(r *ops.Registry, repoURL func(string) string) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		card, sum, err := ogCardFor(req.Context(), r, repo, repoURL(repo), hostOnly(req.Host))
		if err != nil {
			http.NotFound(w, req)
			return
		}
		etag := `"` + sum + `"`
		w.Header().Set("ETag", etag)
		w.Header().Set("Cache-Control", "public, max-age=3600")
		if match := req.Header.Get("If-None-Match"); match != "" && strings.Contains(match, sum) {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		ogCards.Lock()
		body, ok := ogCards.m[sum]
		ogCards.Unlock()
		if !ok {
			if body, err = ogcard.Render(card); err != nil {
				http.Error(w, "the card could not be drawn", http.StatusInternalServerError)
				return
			}
			ogCards.Lock()
			if len(ogCards.m) >= ogCardKeep {
				ogCards.m = map[string][]byte{}
			}
			ogCards.m[sum] = body
			ogCards.Unlock()
		}
		w.Header().Set("Content-Type", "image/png")
		w.Header().Set("Content-Length", fmt.Sprint(len(body)))
		if req.Method != http.MethodHead {
			w.Write(body)
		}
	}
}

// ogCardFor gathers a repository's card and a digest of everything in
// it, which names the rendered image.
func ogCardFor(ctx context.Context, r *ops.Registry, repo, path, site string) (ogcard.Card, string, error) {
	var about struct {
		Description string `json:"description"`
	}
	out, err := invoke(ctx, r, "repo.about", url.Values{"repo": {repo}})
	if err != nil {
		return ogcard.Card{}, "", err
	}
	remarshal(out, &about)
	var counts struct {
		Checkins     int `json:"checkins"`
		Contributors int `json:"contributors"`
		Tickets      int `json:"tickets"`
	}
	if out, err := invoke(ctx, r, "repo.counts", url.Values{"repo": {repo}}); err == nil {
		remarshal(out, &counts)
	}
	card := ogcard.Card{
		Name: repo, Description: about.Description, Site: site,
		Grid: idn.Grid(repo), Color: ogcard.ParseHex(idn.Hex(repo, 0.66)),
	}
	// The address reads owner/name; a repository with no owner path is
	// its name alone.
	if p := strings.TrimPrefix(path, "/r/"); strings.HasSuffix(p, "/"+repo) {
		card.Owner, _ = url.PathUnescape(strings.TrimSuffix(p, "/"+repo))
	}
	// Zero is not shown, as on the page: "0 contributors" reads as a
	// judgement on a new repository.
	for _, s := range []ogcard.Stat{
		{N: counts.Checkins, One: "check-in", Many: "check-ins"},
		{N: counts.Contributors, One: "contributor", Many: "contributors"},
		{N: counts.Tickets, One: "open ticket", Many: "open tickets"},
	} {
		if s.N > 0 {
			card.Stats = append(card.Stats, s)
		}
	}
	avatarTag := ""
	if out, err := invoke(ctx, r, "avatar.get", url.Values{"kind": {"repo"}, "name": {repo}}); err == nil {
		var got struct {
			ETag string `json:"etag"`
			Body string `json:"body"`
		}
		remarshal(out, &got)
		if raw, err := base64.StdEncoding.DecodeString(got.Body); err == nil {
			// An image this cannot decode (an SVG, say) leaves the
			// identicon, which every repository has.
			if img, _, err := image.Decode(bytes.NewReader(raw)); err == nil {
				card.Avatar, avatarTag = img, got.ETag
			}
		}
	}
	h := sha256.New()
	fmt.Fprintf(h, "v1\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d",
		card.Owner, card.Name, card.Description, card.Site, avatarTag,
		counts.Checkins, counts.Contributors, counts.Tickets)
	return card, hex.EncodeToString(h.Sum(nil))[:24], nil
}

// hostOnly is the request's host without a port.
func hostOnly(host string) string {
	if i := strings.LastIndexByte(host, ':'); i >= 0 && !strings.Contains(host[i:], "]") {
		return host[:i]
	}
	return host
}