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/package-lock.json b/package-lock.json index 335290ea4f..c827467875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "postcss-loader": "8.1.1", "postcss-nesting": "13.0.1", "pretty-ms": "9.0.0", - "sortablejs": "1.15.5", + "sortablejs": "1.15.6", "swagger-ui-dist": "5.17.14", "tailwindcss": "3.4.15", "throttle-debounce": "5.0.0", @@ -14596,9 +14596,9 @@ } }, "node_modules/sortablejs": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.5.tgz", - "integrity": "sha512-xDJLosRJzZ+nVnjaUYmO9H/wZth0lWTRq7VzV1eQyDSKsvxmoJ69HTGcwnwGYpJG/AkJ9OWiwWH4BhIycdonWw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", "license": "MIT" }, "node_modules/source-list-map": { diff --git a/package.json b/package.json index 92b3af5e70..135429cfad 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "postcss-loader": "8.1.1", "postcss-nesting": "13.0.1", "pretty-ms": "9.0.0", - "sortablejs": "1.15.5", + "sortablejs": "1.15.6", "swagger-ui-dist": "5.17.14", "tailwindcss": "3.4.15", "throttle-debounce": "5.0.0", diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index b86749ec69..78fb5e6c01 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()) + }) + } +}