package httpd import ( _ "embed" "image" "image/color" "image/draw" "image/png" "net/http" "strconv" "strings" "sync" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/transport" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" ) //go:embed static/fonts/SpaceMono-Regular.ttf var cardRegular []byte //go:embed static/fonts/SpaceMono-Bold.ttf var cardBold []byte const ( cardW, cardH = 1200, 630 cardInset = 48 cardLeft = 104 ) var ( cardOnce sync.Once cardFaces map[int]font.Face cardFaceErr error ) func cardFace(size int, bold bool) font.Face { cardOnce.Do(func() { cardFaces = map[int]font.Face{} for _, spec := range []struct { key int size float64 data []byte }{ {112, 112, cardBold}, {76, 76, cardBold}, {26, 26, cardRegular}, {28, 28, cardRegular}, } { f, err := opentype.Parse(spec.data) if err != nil { cardFaceErr = err return } face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: spec.size, DPI: 72, Hinting: font.HintingFull}) if err != nil { cardFaceErr = err return } cardFaces[spec.key] = face } }) return cardFaces[size] } // cardText draws one line and reports where the next one starts. func cardText(dst *image.RGBA, x, y, size int, s string, c color.Color) { face := cardFace(size, false) if face == nil { return } d := font.Drawer{Dst: dst, Src: image.NewUniform(c), Face: face, Dot: fixed.P(x, y)} d.DrawString(s) } // cardFits shortens a line that would run past the frame, because a card cannot scroll. func cardFits(s string, size, width int) string { face := cardFace(size, false) if face == nil { return s } if font.MeasureString(face, s).Ceil() <= width { return s } for len(s) > 1 { s = s[:len(s)-1] if font.MeasureString(face, s+"...").Ceil() <= width { return s + "..." } } return s } // drawCard is the share image for one page, in the same ink as the pages themselves. func drawCard(title, subtitle, foot string) (*image.RGBA, error) { if cardFace(112, true); cardFaceErr != nil { return nil, cardFaceErr } img := image.NewRGBA(image.Rect(0, 0, cardW, cardH)) draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) border := color.RGBA{0xdd, 0xdd, 0xdd, 0xff} frame := image.Rect(cardInset, cardInset, cardW-cardInset, cardH-cardInset) for x := frame.Min.X; x < frame.Max.X; x++ { img.Set(x, frame.Min.Y, border) img.Set(x, frame.Min.Y+1, border) img.Set(x, frame.Max.Y-1, border) img.Set(x, frame.Max.Y-2, border) } for y := frame.Min.Y; y < frame.Max.Y; y++ { img.Set(frame.Min.X, y, border) img.Set(frame.Min.X+1, y, border) img.Set(frame.Max.X-1, y, border) img.Set(frame.Max.X-2, y, border) } width := cardW - cardLeft - cardInset - 56 size := 112 if len(title) > 12 { size = 76 } cardText(img, cardLeft, 232, size, cardFits(title, size, width), color.Black) if subtitle != "" { cardText(img, cardLeft+2, 296, 28, cardFits(subtitle, 28, width), color.RGBA{0x55, 0x55, 0x55, 0xff}) } for x := cardLeft; x < cardW-cardInset-56; x++ { img.Set(x, 360, border) img.Set(x, 361, border) } cardText(img, cardLeft+2, 530, 26, cardFits(foot, 26, width), color.Black) return img, nil } // serveCard draws the share image for a page, so a link to it says whose it is. func (s *Server) serveCard(w http.ResponseWriter, r *http.Request, title, subtitle, foot string) { img, err := drawCard(title, subtitle, foot) if err != nil { s.oops(w, r, err) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=300") png.Encode(w, img) } // cardHost is the domain a card signs itself with, without the scheme. func cardHost(site string) string { host := strings.TrimPrefix(strings.TrimPrefix(site, "https://"), "http://") return strings.TrimSuffix(host, "/") } // serveNamedCard draws the share image for an account or a repository, and refuses anything else. func (s *Server) serveNamedCard(w http.ResponseWriter, r *http.Request, parts []string) { foot := cardHost(s.Cfg.Server.ExternalURL) switch len(parts) { case 1: owner := parts[0] if !gitx.ValidName(owner) { s.notFound(w, r) return } if _, err := s.DB.Account(r.Context(), owner); err != nil { s.notFound(w, r) return } // Only what this reader may see, so a card cannot count somebody's private work. names, _ := s.DB.ReposOf(r.Context(), owner) n := 0 for _, repo := range names { if _, err := s.Transport.Open(r.Context(), owner, repo, s.viewer(r), transport.Read); err == nil { n++ } } s.serveCard(w, r, owner, plural(n, "repository", "repositories"), foot+"/"+owner) case 2: owner, name := parts[0], parts[1] if !gitx.ValidName(owner) || !gitx.ValidRepoName(name) { s.notFound(w, r) return } // A card is public, so it must not say a private repository exists. res, err := s.Transport.Open(r.Context(), owner, name, s.viewer(r), transport.Read) if err != nil || res == nil { s.notFound(w, r) return } // The count, not the description, so the line is the same shape as the profile card's. n := 0 if out, err := gitx.Run(r.Context(), res.Dir, "rev-list", "--count", "HEAD"); err == nil { n, _ = strconv.Atoi(strings.TrimSpace(out)) } s.serveCard(w, r, owner+"/"+name, plural(n, "commit", "commits"), foot+"/"+owner+"/"+name) default: s.notFound(w, r) } }