diff --git a/go.mod b/go.mod
index b4db009c73..f70c7096af 100644
--- a/go.mod
+++ b/go.mod
@@ -54,6 +54,7 @@ require (
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang-jwt/jwt/v5 v5.2.1
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/go-github/v64 v64.0.0
github.com/google/pprof v0.0.0-20241017200806-017d972448fc
github.com/google/uuid v1.6.0
diff --git a/go.sum b/go.sum
index 54ac6bb5ae..c6f8b1e185 100644
--- a/go.sum
+++ b/go.sum
@@ -320,6 +320,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 13991da767..7d1a5ca407 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -411,6 +411,25 @@ func (issue *Issue) HTMLURL() string {
return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index)
}
+// SummaryCardURL returns the absolute URL to an image providing a summary of the issue
+func (issue *Issue) SummaryCardURL() string {
+ return fmt.Sprintf("%s/summary-card", issue.HTMLURL())
+}
+
+func (issue *Issue) SummaryCardSize() (int, int) {
+ return 1200, 600
+}
+
+func (issue *Issue) SummaryCardWidth() int {
+ width, _ := issue.SummaryCardSize()
+ return width
+}
+
+func (issue *Issue) SummaryCardHeight() int {
+ _, height := issue.SummaryCardSize()
+ return height
+}
+
// Link returns the issue's relative URL.
func (issue *Issue) Link() string {
var path string
diff --git a/modules/card/card.go b/modules/card/card.go
new file mode 100644
index 0000000000..bb160d7ea3
--- /dev/null
+++ b/modules/card/card.go
@@ -0,0 +1,323 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package card
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "io"
+ "math"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ _ "image/gif" // for processing gif images
+ _ "image/jpeg" // for processing jpeg images
+ _ "image/png" // for processing png images
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/proxy"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/golang/freetype"
+ "github.com/golang/freetype/truetype"
+ "golang.org/x/image/draw"
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/gofont/goregular"
+
+ _ "golang.org/x/image/webp" // for processing webp images
+)
+
+type Card struct {
+ Img *image.RGBA
+ Font *truetype.Font
+ Margin int
+}
+
+var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
+ return truetype.Parse(goregular.TTF)
+})
+
+// NewCard creates a new card with the given dimensions in pixels
+func NewCard(width, height int) (*Card, error) {
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+ draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
+
+ font, err := fontCache()
+ if err != nil {
+ return nil, err
+ }
+
+ return &Card{
+ Img: img,
+ Font: font,
+ Margin: 0,
+ }, nil
+}
+
+// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
+// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
+func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
+ bounds := c.Img.Bounds()
+ bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ if vertical {
+ mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
+ subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
+ subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
+ return &Card{Img: subleft, Font: c.Font},
+ &Card{Img: subright, Font: c.Font}
+ }
+ mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
+ subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
+ subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
+ return &Card{Img: subtop, Font: c.Font},
+ &Card{Img: subbottom, Font: c.Font}
+}
+
+// SetMargin sets the margins for the card
+func (c *Card) SetMargin(margin int) {
+ c.Margin = margin
+}
+
+type (
+ VAlign int64
+ HAlign int64
+)
+
+const (
+ Top VAlign = iota
+ Middle
+ Bottom
+)
+
+const (
+ Left HAlign = iota
+ Center
+ Right
+)
+
+// DrawText draws text within the card, respecting margins and alignment
+func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
+ ft := freetype.NewContext()
+ ft.SetDPI(72)
+ ft.SetFont(c.Font)
+ ft.SetFontSize(sizePt)
+ ft.SetClip(c.Img.Bounds())
+ ft.SetDst(c.Img)
+ ft.SetSrc(image.NewUniform(textColor))
+
+ face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
+ fontHeight := ft.PointToFixed(sizePt).Ceil()
+
+ bounds := c.Img.Bounds()
+ bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
+ // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
+
+ // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
+ // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
+ // knowing the total height, which is related to how many lines we'll have.
+ lines := make([]string, 0)
+ textWords := strings.Split(text, " ")
+ currentLine := ""
+ heightTotal := 0
+
+ for {
+ if len(textWords) == 0 {
+ // Ran out of words.
+ if currentLine != "" {
+ heightTotal += fontHeight
+ lines = append(lines, currentLine)
+ }
+ break
+ }
+
+ nextWord := textWords[0]
+ proposedLine := currentLine
+ if proposedLine != "" {
+ proposedLine += " "
+ }
+ proposedLine += nextWord
+
+ proposedLineWidth := font.MeasureString(face, proposedLine)
+ if proposedLineWidth.Ceil() > boxWidth {
+ // no, proposed line is too big; we'll use the last "currentLine"
+ heightTotal += fontHeight
+ if currentLine != "" {
+ lines = append(lines, currentLine)
+ currentLine = ""
+ // leave nextWord in textWords and keep going
+ } else {
+ // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
+ // regardless as a line by itself. It will be clipped by the drawing routine.
+ lines = append(lines, nextWord)
+ textWords = textWords[1:]
+ }
+ } else {
+ // yes, it will fit
+ currentLine = proposedLine
+ textWords = textWords[1:]
+ }
+ }
+
+ textY := 0
+ switch valign {
+ case Top:
+ textY = fontHeight
+ case Bottom:
+ textY = boxHeight - heightTotal + fontHeight
+ case Middle:
+ textY = ((boxHeight - heightTotal) / 2) + fontHeight
+ }
+
+ for _, line := range lines {
+ lineWidth := font.MeasureString(face, line)
+
+ textX := 0
+ switch halign {
+ case Left:
+ textX = 0
+ case Right:
+ textX = boxWidth - lineWidth.Ceil()
+ case Center:
+ textX = (boxWidth - lineWidth.Ceil()) / 2
+ }
+
+ pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
+ _, err := ft.DrawString(line, pt)
+ if err != nil {
+ return nil, err
+ }
+
+ textY += fontHeight
+ }
+
+ return lines, nil
+}
+
+// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
+func (c *Card) DrawImage(img image.Image) {
+ bounds := c.Img.Bounds()
+ targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ srcBounds := img.Bounds()
+ srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
+ targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
+
+ var scale float64
+ if srcAspect > targetAspect {
+ // Image is wider than target, scale by width
+ scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
+ } else {
+ // Image is taller or equal, scale by height
+ scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
+ }
+
+ newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
+ newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
+
+ // Center the image within the target rectangle
+ offsetX := (targetRect.Dx() - newWidth) / 2
+ offsetY := (targetRect.Dy() - newHeight) / 2
+
+ scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
+ draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
+}
+
+func fallbackImage() image.Image {
+ // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+ img.Set(0, 0, color.White)
+ return img
+}
+
+// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
+func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
+ // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
+ // this rendering process to be slowed down
+ client := &http.Client{
+ Timeout: 1 * time.Second, // 1 second timeout
+ Transport: &http.Transport{
+ Proxy: proxy.Proxy(),
+ },
+ }
+
+ resp, err := client.Get(url)
+ if err != nil {
+ log.Warn("error when fetching external image from %s: %w", url, err)
+ return nil, false
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ log.Warn("non-OK error code when fetching external image from %s: %s", url, resp.Status)
+ return nil, false
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+ // Support content types are in-sync with the allowed custom avatar file types
+ if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
+ log.Warn("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
+ return nil, false
+ }
+
+ body := io.LimitReader(resp.Body, setting.Avatar.MaxFileSize)
+ bodyBytes, err := io.ReadAll(body)
+ if err != nil {
+ log.Warn("error when fetching external image from %s: %w", url, err)
+ return nil, false
+ }
+ if int64(len(bodyBytes)) == setting.Avatar.MaxFileSize {
+ log.Warn("while fetching external image response size hit MaxFileSize (%d) and was discarded from url %s", setting.Avatar.MaxFileSize, url)
+ return nil, false
+ }
+
+ bodyBuffer := bytes.NewReader(bodyBytes)
+ imgCfg, imgType, err := image.DecodeConfig(bodyBuffer)
+ if err != nil {
+ log.Warn("error when decoding external image from %s: %w", url, err)
+ return nil, false
+ }
+
+ // Verify that we have a match between actual data understood in the image body and the reported Content-Type
+ if (contentType == "image/png" && imgType != "png") ||
+ (contentType == "image/jpeg" && imgType != "jpeg") ||
+ (contentType == "image/gif" && imgType != "gif") ||
+ (contentType == "image/webp" && imgType != "webp") {
+ log.Warn("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
+ return nil, false
+ }
+
+ // do not process image which is too large, it would consume too much memory
+ if imgCfg.Width > setting.Avatar.MaxWidth {
+ log.Warn("while fetching external image, width %d exceeds Avatar.MaxWidth %d", imgCfg.Width, setting.Avatar.MaxWidth)
+ return nil, false
+ }
+ if imgCfg.Height > setting.Avatar.MaxHeight {
+ log.Warn("while fetching external image, height %d exceeds Avatar.MaxHeight %d", imgCfg.Height, setting.Avatar.MaxHeight)
+ return nil, false
+ }
+
+ _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
+ if err != nil {
+ log.Warn("error w/ bodyBuffer.Seek")
+ return nil, false
+ }
+ img, _, err := image.Decode(bodyBuffer)
+ if err != nil {
+ log.Warn("error when decoding external image from %s: %w", url, err)
+ return nil, false
+ }
+
+ return img, true
+}
+
+func (c *Card) DrawExternalImage(url string) {
+ image, ok := c.fetchExternalImage(url)
+ if !ok {
+ image = fallbackImage()
+ }
+ c.DrawImage(image)
+}
diff --git a/modules/card/card_test.go b/modules/card/card_test.go
new file mode 100644
index 0000000000..27753794c4
--- /dev/null
+++ b/modules/card/card_test.go
@@ -0,0 +1,244 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package card
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/golang/freetype/truetype"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/image/font/gofont/goregular"
+)
+
+func TestNewCard(t *testing.T) {
+ width, height := 100, 50
+ card, err := NewCard(width, height)
+ require.NoError(t, err, "No error should occur when creating a new card")
+ assert.NotNil(t, card, "Card should not be nil")
+ assert.Equal(t, width, card.Img.Bounds().Dx(), "Width should match the provided width")
+ assert.Equal(t, height, card.Img.Bounds().Dy(), "Height should match the provided height")
+
+ // Checking default margin
+ assert.Equal(t, 0, card.Margin, "Default margin should be 0")
+
+ // Checking font parsing
+ originalFont, _ := truetype.Parse(goregular.TTF)
+ assert.Equal(t, originalFont, card.Font, "Fonts should be equivalent")
+}
+
+func TestSplit(t *testing.T) {
+ // Note: you normally wouldn't split the same card twice as draw operations would start to overlap each other; but
+ // it's fine for this limited scope test
+ card, _ := NewCard(200, 100)
+
+ // Test vertical split
+ leftCard, rightCard := card.Split(true, 50)
+ assert.Equal(t, 100, leftCard.Img.Bounds().Dx(), "Left card should have half the width of original")
+ assert.Equal(t, 100, leftCard.Img.Bounds().Dy(), "Left card height unchanged by split")
+ assert.Equal(t, 100, rightCard.Img.Bounds().Dx(), "Right card should have half the width of original")
+ assert.Equal(t, 100, rightCard.Img.Bounds().Dy(), "Right card height unchanged by split")
+
+ // Test horizontal split
+ topCard, bottomCard := card.Split(false, 50)
+ assert.Equal(t, 200, topCard.Img.Bounds().Dx(), "Top card width unchanged by split")
+ assert.Equal(t, 50, topCard.Img.Bounds().Dy(), "Top card should have half the height of original")
+ assert.Equal(t, 200, bottomCard.Img.Bounds().Dx(), "Bottom width unchanged by split")
+ assert.Equal(t, 50, bottomCard.Img.Bounds().Dy(), "Bottom card should have half the height of original")
+}
+
+func TestDrawTextSingleLine(t *testing.T) {
+ card, _ := NewCard(300, 100)
+ lines, err := card.DrawText("This is a single line", color.Black, 12, Middle, Center)
+ require.NoError(t, err, "No error should occur when drawing text")
+ assert.Len(t, lines, 1, "Should be exactly one line")
+ assert.Equal(t, "This is a single line", lines[0], "Text should match the input")
+}
+
+func TestDrawTextLongLine(t *testing.T) {
+ card, _ := NewCard(300, 100)
+ text := "This text is definitely too long to fit in three hundred pixels width without wrapping"
+ lines, err := card.DrawText(text, color.Black, 12, Middle, Center)
+ require.NoError(t, err, "No error should occur when drawing text")
+ assert.Len(t, lines, 2, "Text should wrap into multiple lines")
+ assert.Equal(t, "This text is definitely too long to fit in three hundred", lines[0], "Text should match the input")
+ assert.Equal(t, "pixels width without wrapping", lines[1], "Text should match the input")
+}
+
+func TestDrawTextWordTooLong(t *testing.T) {
+ card, _ := NewCard(300, 100)
+ text := "Line 1 Superduperlongwordthatcannotbewrappedbutshouldenduponitsownsingleline Line 3"
+ lines, err := card.DrawText(text, color.Black, 12, Middle, Center)
+ require.NoError(t, err, "No error should occur when drawing text")
+ assert.Len(t, lines, 3, "Text should create two lines despite long word")
+ assert.Equal(t, "Line 1", lines[0], "First line should contain text before the long word")
+ assert.Equal(t, "Superduperlongwordthatcannotbewrappedbutshouldenduponitsownsingleline", lines[1], "Second line couldn't wrap the word so it just overflowed")
+ assert.Equal(t, "Line 3", lines[2], "Third line continued with wrapping")
+}
+
+func TestFetchExternalImageServer(t *testing.T) {
+ blackPng, err := base64.URLEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+
+ var tooWideBuf bytes.Buffer
+ imgTooWide := image.NewGray(image.Rect(0, 0, 16001, 10))
+ err = png.Encode(&tooWideBuf, imgTooWide)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ imgTooWidePng := tooWideBuf.Bytes()
+
+ var tooTallBuf bytes.Buffer
+ imgTooTall := image.NewGray(image.Rect(0, 0, 10, 16002))
+ err = png.Encode(&tooTallBuf, imgTooTall)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ imgTooTallPng := tooTallBuf.Bytes()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/timeout":
+ // Simulate a timeout by taking a long time to respond
+ time.Sleep(8 * time.Second)
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(blackPng)
+ case "/notfound":
+ http.NotFound(w, r)
+ case "/image.png":
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(blackPng)
+ case "/weird-content":
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(""))
+ case "/giant-response":
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(make([]byte, 10485760))
+ case "/invalid.png":
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(make([]byte, 100))
+ case "/mismatched.jpg":
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Write(blackPng) // valid png, but wrong content-type
+ case "/too-wide.png":
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(imgTooWidePng)
+ case "/too-tall.png":
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(imgTooTallPng)
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ }))
+ defer server.Close()
+
+ tests := []struct {
+ name string
+ url string
+ expectedSuccess bool
+ expectedLog string
+ }{
+ {
+ name: "timeout error",
+ url: "/timeout",
+ expectedSuccess: false,
+ expectedLog: "error when fetching external image from",
+ },
+ {
+ name: "external fetch success",
+ url: "/image.png",
+ expectedSuccess: true,
+ expectedLog: "",
+ },
+ {
+ name: "404 fallback",
+ url: "/notfound",
+ expectedSuccess: false,
+ expectedLog: "non-OK error code when fetching external image",
+ },
+ {
+ name: "unsupported content type",
+ url: "/weird-content",
+ expectedSuccess: false,
+ expectedLog: "fetching external image returned unsupported Content-Type",
+ },
+ {
+ name: "response too large",
+ url: "/giant-response",
+ expectedSuccess: false,
+ expectedLog: "while fetching external image response size hit MaxFileSize",
+ },
+ {
+ name: "invalid png",
+ url: "/invalid.png",
+ expectedSuccess: false,
+ expectedLog: "error when decoding external image",
+ },
+ {
+ name: "mismatched content type",
+ url: "/mismatched.jpg",
+ expectedSuccess: false,
+ expectedLog: "while fetching external image, mismatched image body",
+ },
+ {
+ name: "too wide",
+ url: "/too-wide.png",
+ expectedSuccess: false,
+ expectedLog: "while fetching external image, width 16001 exceeds Avatar.MaxWidth",
+ },
+ {
+ name: "too tall",
+ url: "/too-tall.png",
+ expectedSuccess: false,
+ expectedLog: "while fetching external image, height 16002 exceeds Avatar.MaxHeight",
+ },
+ }
+
+ for _, testCase := range tests {
+ t.Run(testCase.name, func(t *testing.T) {
+ // stopMark is used as a logging boundary to verify that the expected message (testCase.expectedLog) is
+ // logged during the `fetchExternalImage` operation. This is verified by a combination of checking that the
+ // stopMark message was received, and that the filtered log (logFiltered[0]) was received.
+ stopMark := fmt.Sprintf(">>>>>>>>>>>>>STOP: %s<<<<<<<<<<<<<<<", testCase.name)
+
+ logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE)
+ logChecker.Filter(testCase.expectedLog).StopMark(stopMark)
+ defer cleanup()
+
+ card, _ := NewCard(100, 100)
+ img, ok := card.fetchExternalImage(server.URL + testCase.url)
+
+ if testCase.expectedSuccess {
+ assert.True(t, ok, "expected success from fetchExternalImage")
+ assert.NotNil(t, img)
+ } else {
+ assert.False(t, ok, "expected failure from fetchExternalImage")
+ assert.Nil(t, img)
+ }
+
+ log.Info(stopMark)
+
+ logFiltered, logStopped := logChecker.Check(5 * time.Second)
+ assert.True(t, logStopped, "failed to find log stop mark")
+ assert.True(t, logFiltered[0], "failed to find in log: '%s'", testCase.expectedLog)
+ })
+ }
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 91a6b1bf4e..1d01c0d54a 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1622,6 +1622,8 @@ issues.all_title = All
issues.draft_title = Draft
issues.num_comments_1 = %d comment
issues.num_comments = %d comments
+issues.num_reviews_one = %d review
+issues.num_reviews_few = %d reviews
issues.commented_at = `commented %s`
issues.delete_comment_confirm = Are you sure you want to delete this comment?
issues.context.copy_link = Copy link
@@ -1831,6 +1833,7 @@ issues.content_history.options = Options
issues.reference_link = Reference: %s
issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner.
issues.comment.blocked_by_user = You cannot create a comment on this issue because you are blocked by the repository owner or the poster of the issue.
+issues.summary_card_alt = Summary card of an issue titled "%s" in repository %s
compare.compare_base = base
compare.compare_head = compare
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index e67980a9a9..564946829e 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -10,6 +10,9 @@ import (
"errors"
"fmt"
"html/template"
+ "image"
+ "image/color"
+ "image/png"
"math/big"
"net/http"
"net/url"
@@ -31,6 +34,8 @@ import (
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/card"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/git"
@@ -42,6 +47,7 @@ import (
"code.gitea.io/gitea/modules/optional"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/templates/vars"
@@ -2212,6 +2218,222 @@ func GetIssueInfo(ctx *context.Context) {
ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue))
}
+// GetSummaryCard get an issue of a repository
+func GetSummaryCard(ctx *context.Context) {
+ issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrIssueNotExist(err) {
+ ctx.Error(http.StatusNotFound)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
+ }
+ return
+ }
+
+ if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ cache := cache.GetCache()
+ cacheKey := fmt.Sprintf("summary_card:issue:%s:%d", ctx.Locale.Language(), issue.ID)
+ pngData, ok := cache.Get(cacheKey).([]byte)
+ if ok && pngData != nil && len(pngData) > 0 {
+ ctx.Resp.Header().Set("Content-Type", "image/png")
+ ctx.Resp.WriteHeader(http.StatusOK)
+ _, err = ctx.Resp.Write(pngData)
+ if err != nil {
+ ctx.ServerError("GetSummaryCard", err)
+ }
+ return
+ }
+
+ card, err := drawSummaryCard(ctx, issue)
+ if err != nil {
+ ctx.ServerError("GetSummaryCard", err)
+ return
+ }
+
+ // Encode image, store in cache
+ var imageBuffer bytes.Buffer
+ err = png.Encode(&imageBuffer, card.Img)
+ if err != nil {
+ ctx.ServerError("GetSummaryCard", err)
+ return
+ }
+ imageBytes := imageBuffer.Bytes()
+ err = cache.Put(cacheKey, imageBytes, setting.CacheService.TTLSeconds())
+ if err != nil {
+ // don't abort serving the image if we just had a cache storage failure
+ log.Warn("failed to cache issue summary card: %v", err)
+ }
+
+ // Finish the uncached image response
+ ctx.Resp.Header().Set("Content-Type", "image/png")
+ ctx.Resp.WriteHeader(http.StatusOK)
+ _, err = ctx.Resp.Write(imageBytes)
+ if err != nil {
+ ctx.ServerError("GetSummaryCard", err)
+ return
+ }
+}
+
+func drawSummaryCard(ctx *context.Context, issue *issues_model.Issue) (*card.Card, error) {
+ width, height := issue.SummaryCardSize()
+ mainCard, err := card.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ mainCard.SetMargin(60)
+ topSection, bottomSection := mainCard.Split(false, 75)
+ issueSummary, issueIcon := topSection.Split(true, 80)
+ repoInfo, issueDescription := issueSummary.Split(false, 15)
+
+ repoInfo.SetMargin(10)
+ _, err = repoInfo.DrawText(fmt.Sprintf("%s - #%d", issue.Repo.FullName(), issue.Index), color.Gray{128}, 36, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ issueDescription.SetMargin(10)
+ _, err = issueDescription.DrawText(issue.Title, color.Black, 56, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ issueIcon.SetMargin(10)
+
+ repoAvatarPath := issue.Repo.CustomAvatarRelativePath()
+ if repoAvatarPath != "" {
+ repoAvatarFile, err := storage.RepoAvatars.Open(repoAvatarPath)
+ if err != nil {
+ return nil, err
+ }
+ repoAvatarImage, _, err := image.Decode(repoAvatarFile)
+ if err != nil {
+ return nil, err
+ }
+ issueIcon.DrawImage(repoAvatarImage)
+ } else {
+ // If the repo didn't have an avatar, fallback to the repo owner's avatar for the right-hand-side icon
+ err = issue.Repo.LoadOwner(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if issue.Repo.Owner != nil {
+ err = drawUser(ctx, issueIcon, issue.Repo.Owner)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ issueStats, issueAttribution := bottomSection.Split(false, 50)
+
+ var state string
+ if issue.IsPull && issue.PullRequest.HasMerged {
+ if issue.PullRequest.Status == 3 {
+ state = ctx.Locale.TrString("repo.pulls.manually_merged")
+ } else {
+ state = ctx.Locale.TrString("repo.pulls.merged")
+ }
+ } else if issue.IsClosed {
+ state = ctx.Locale.TrString("repo.issues.closed_title")
+ } else if issue.IsPull {
+ if issue.PullRequest.IsWorkInProgress(ctx) {
+ state = ctx.Locale.TrString("repo.issues.draft_title")
+ } else {
+ state = ctx.Locale.TrString("repo.issues.open_title")
+ }
+ } else {
+ state = ctx.Locale.TrString("repo.issues.open_title")
+ }
+ state = strings.ToLower(state)
+
+ issueStats.SetMargin(10)
+ if issue.IsPull {
+ reviews := map[int64]bool{}
+ for _, comment := range issue.Comments {
+ if comment.Review != nil {
+ reviews[comment.Review.ID] = true
+ }
+ }
+ _, err = issueStats.DrawText(
+ fmt.Sprintf("%s, %s, %s",
+ ctx.Locale.TrN(
+ issue.NumComments,
+ "repo.issues.num_comments_1",
+ "repo.issues.num_comments",
+ issue.NumComments,
+ ),
+ ctx.Locale.TrN(
+ len(reviews),
+ "repo.issues.num_reviews_one",
+ "repo.issues.num_reviews_few",
+ len(reviews),
+ ),
+ state,
+ ),
+ color.Gray{128}, 36, card.Top, card.Left)
+ } else {
+ _, err = issueStats.DrawText(
+ fmt.Sprintf("%s, %s",
+ ctx.Locale.TrN(
+ issue.NumComments,
+ "repo.issues.num_comments_1",
+ "repo.issues.num_comments",
+ issue.NumComments,
+ ),
+ state,
+ ),
+ color.Gray{128}, 36, card.Top, card.Left)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ issueAttributionIcon, issueAttributionText := issueAttribution.Split(true, 8)
+ issueAttributionText.SetMargin(5)
+ _, err = issueAttributionText.DrawText(
+ fmt.Sprintf(
+ "%s - %s",
+ issue.Poster.Name,
+ issue.Created.AsTime().Format("2006-01-02"),
+ ),
+ color.Gray{128}, 36, card.Middle, card.Left)
+ if err != nil {
+ return nil, err
+ }
+ err = drawUser(ctx, issueAttributionIcon, issue.Poster)
+ if err != nil {
+ return nil, err
+ }
+
+ return mainCard, nil
+}
+
+func drawUser(ctx *context.Context, card *card.Card, user *user_model.User) error {
+ if user.UseCustomAvatar {
+ posterAvatarPath := user.CustomAvatarRelativePath()
+ if posterAvatarPath != "" {
+ userAvatarFile, err := storage.Avatars.Open(user.CustomAvatarRelativePath())
+ if err != nil {
+ return err
+ }
+ userAvatarImage, _, err := image.Decode(userAvatarFile)
+ if err != nil {
+ return err
+ }
+ card.DrawImage(userAvatarImage)
+ }
+ } else {
+ posterAvatarLink := user.AvatarLinkWithSize(ctx, 256)
+ card.DrawExternalImage(posterAvatarLink)
+ }
+ return nil
+}
+
// UpdateIssueTitle change issue's title
func UpdateIssueTitle(ctx *context.Context) {
issue := GetActionIssue(ctx)
diff --git a/routers/web/web.go b/routers/web/web.go
index fdfda68b1e..6061863895 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1146,6 +1146,7 @@ func registerRoutes(m *web.Route) {
m.Group("/{type:issues|pulls}", func() {
m.Group("/{index}", func() {
m.Get("/info", repo.GetIssueInfo)
+ m.Get("/summary-card", repo.GetSummaryCard)
})
})
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication
diff --git a/templates/base/head_opengraph.tmpl b/templates/base/head_opengraph.tmpl
index 292c3bdd92..be9829bf97 100644
--- a/templates/base/head_opengraph.tmpl
+++ b/templates/base/head_opengraph.tmpl
@@ -14,6 +14,10 @@
{{if .Issue.Content}}
{{end}}
+
+
+
+
{{else if or .PageIsDiff .IsViewFile}}
@@ -38,10 +42,12 @@
{{end}}
{{end}}
- {{if (.Repository.AvatarLink ctx)}}
-
- {{else}}
-
+ {{if not .Issue}}
+ {{if (.Repository.AvatarLink ctx)}}
+
+ {{else}}
+
+ {{end}}
{{end}}
{{else}}
diff --git a/tests/integration/opengraph_test.go b/tests/integration/opengraph_test.go
index 69eef7f0d9..40013bd247 100644
--- a/tests/integration/opengraph_test.go
+++ b/tests/integration/opengraph_test.go
@@ -4,8 +4,8 @@
package integration
import (
+ "image"
"net/http"
- "strings"
"testing"
"code.gitea.io/gitea/modules/setting"
@@ -13,6 +13,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestOpenGraphProperties(t *testing.T) {
@@ -43,7 +44,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "User Thirty",
"og:url": setting.AppURL + "user30",
"og:type": "profile",
- "og:image": "http://localhost:3003/assets/img/avatar_default.png",
+ "og:image": setting.AppURL + "assets/img/avatar_default.png",
"og:site_name": siteName,
},
},
@@ -55,7 +56,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "the_34-user.with.all.allowedChars",
"og:description": "some [commonmark](https://commonmark.org/)!",
"og:type": "profile",
- "og:image": "http://localhost:3003/assets/img/avatar_default.png",
+ "og:image": setting.AppURL + "assets/img/avatar_default.png",
"og:site_name": siteName,
},
},
@@ -63,24 +64,30 @@ func TestOpenGraphProperties(t *testing.T) {
name: "issue",
url: "/user2/repo1/issues/1",
expected: map[string]string{
- "og:title": "issue1",
- "og:url": setting.AppURL + "user2/repo1/issues/1",
- "og:description": "content for the first issue",
- "og:type": "object",
- "og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
- "og:site_name": siteName,
+ "og:title": "issue1",
+ "og:url": setting.AppURL + "user2/repo1/issues/1",
+ "og:description": "content for the first issue",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user2/repo1/issues/1/summary-card",
+ "og:image:alt": "Summary card of an issue titled \"issue1\" in repository user2/repo1",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
{
name: "pull request",
url: "/user2/repo1/pulls/2",
expected: map[string]string{
- "og:title": "issue2",
- "og:url": setting.AppURL + "user2/repo1/pulls/2",
- "og:description": "content for the second issue",
- "og:type": "object",
- "og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
- "og:site_name": siteName,
+ "og:title": "issue2",
+ "og:url": setting.AppURL + "user2/repo1/pulls/2",
+ "og:description": "content for the second issue",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user2/repo1/pulls/2/summary-card",
+ "og:image:alt": "Summary card of an issue titled \"issue2\" in repository user2/repo1",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
{
@@ -90,7 +97,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "repo49/test/test.txt at master",
"og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt",
"og:type": "object",
- "og:image": "http://localhost:3003/assets/img/avatar_default.png",
+ "og:image": setting.AppURL + "assets/img/avatar_default.png",
"og:site_name": siteName,
},
},
@@ -101,7 +108,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "Page With Spaced Name",
"og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
"og:type": "object",
- "og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
+ "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
"og:site_name": siteName,
},
},
@@ -112,7 +119,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "repo1",
"og:url": setting.AppURL + "user2/repo1",
"og:type": "object",
- "og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
+ "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
"og:site_name": siteName,
},
},
@@ -124,7 +131,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "user27/repo49",
"og:description": "A wonderful repository with more than just a README.md",
"og:type": "object",
- "og:image": "http://localhost:3003/assets/img/avatar_default.png",
+ "og:image": setting.AppURL + "assets/img/avatar_default.png",
"og:site_name": siteName,
},
},
@@ -132,6 +139,8 @@ func TestOpenGraphProperties(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
req := NewRequest(t, "GET", tc.url)
resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
@@ -142,10 +151,6 @@ func TestOpenGraphProperties(t *testing.T) {
assert.True(t, foundProp)
content, foundContent := selection.Attr("content")
assert.True(t, foundContent, "opengraph meta tag without a content property")
- if prop == "og:image" {
- content = strings.ReplaceAll(content, "http://localhost:3001", "http://localhost:3003")
- content = strings.ReplaceAll(content, "http://localhost:3002", "http://localhost:3003")
- }
foundProps[prop] = content
})
@@ -153,3 +158,37 @@ func TestOpenGraphProperties(t *testing.T) {
})
}
}
+
+func TestOpenGraphSummaryCard(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ cases := []struct {
+ name string
+ url string
+ }{
+ {
+ name: "issue",
+ url: "/user2/repo1/issues/1/summary-card",
+ },
+ {
+ name: "pull request",
+ url: "/user2/repo1/pulls/2/summary-card",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", tc.url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "image/png", resp.Header().Get("Content-Type"))
+ img, imgType, err := image.Decode(resp.Body)
+ require.NoError(t, err)
+ assert.Equal(t, "png", imgType)
+ assert.Equal(t, 1200, img.Bounds().Dx())
+ assert.Equal(t, 600, img.Bounds().Dy())
+ })
+ }
+}