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))
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 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,
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
}
}
}
// No upload: the forge's own default where it has one (quarry's
// repository wears quarry's logo, as on the page), else the
// identicon the fields above already carry.
if card.Avatar == nil {
if _, ok := builtinMark[repo]; ok {
card.Builtin = repo
}
}
h := sha256.New()
fmt.Fprintf(h, "v3\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d",
card.Builtin, card.Owner, card.Name, card.Description, avatarTag,
counts.Checkins, counts.Contributors, counts.Tickets)
return card, hex.EncodeToString(h.Sum(nil))[:24], nil
}