Merge branch 'forgejo' into forgejo

This commit is contained in:
Fermé le Lundi 2024-11-29 15:37:59 +00:00
commit 75f703326f
12 changed files with 892 additions and 32 deletions

1
go.mod
View file

@ -54,6 +54,7 @@ require (
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang-jwt/jwt/v5 v5.2.1 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/go-github/v64 v64.0.0
github.com/google/pprof v0.0.0-20241017200806-017d972448fc github.com/google/pprof v0.0.0-20241017200806-017d972448fc
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0

2
go.sum
View file

@ -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/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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 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 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= 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= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=

View file

@ -411,6 +411,25 @@ func (issue *Issue) HTMLURL() string {
return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index) 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. // Link returns the issue's relative URL.
func (issue *Issue) Link() string { func (issue *Issue) Link() string {
var path string var path string

323
modules/card/card.go Normal file
View file

@ -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)
}

244
modules/card/card_test.go Normal file
View file

@ -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("<html></html>"))
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)
})
}
}

View file

@ -1622,6 +1622,8 @@ issues.all_title = All
issues.draft_title = Draft issues.draft_title = Draft
issues.num_comments_1 = %d comment issues.num_comments_1 = %d comment
issues.num_comments = %d comments issues.num_comments = %d comments
issues.num_reviews_one = %d review
issues.num_reviews_few = %d reviews
issues.commented_at = `commented <a href="#%s">%s</a>` issues.commented_at = `commented <a href="#%s">%s</a>`
issues.delete_comment_confirm = Are you sure you want to delete this comment? issues.delete_comment_confirm = Are you sure you want to delete this comment?
issues.context.copy_link = Copy link issues.context.copy_link = Copy link
@ -1831,6 +1833,7 @@ issues.content_history.options = Options
issues.reference_link = Reference: %s 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.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.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_base = base
compare.compare_head = compare compare.compare_head = compare

8
package-lock.json generated
View file

@ -41,7 +41,7 @@
"postcss-loader": "8.1.1", "postcss-loader": "8.1.1",
"postcss-nesting": "13.0.1", "postcss-nesting": "13.0.1",
"pretty-ms": "9.0.0", "pretty-ms": "9.0.0",
"sortablejs": "1.15.5", "sortablejs": "1.15.6",
"swagger-ui-dist": "5.17.14", "swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
@ -14596,9 +14596,9 @@
} }
}, },
"node_modules/sortablejs": { "node_modules/sortablejs": {
"version": "1.15.5", "version": "1.15.6",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.5.tgz", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
"integrity": "sha512-xDJLosRJzZ+nVnjaUYmO9H/wZth0lWTRq7VzV1eQyDSKsvxmoJ69HTGcwnwGYpJG/AkJ9OWiwWH4BhIycdonWw==", "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/source-list-map": { "node_modules/source-list-map": {

View file

@ -40,7 +40,7 @@
"postcss-loader": "8.1.1", "postcss-loader": "8.1.1",
"postcss-nesting": "13.0.1", "postcss-nesting": "13.0.1",
"pretty-ms": "9.0.0", "pretty-ms": "9.0.0",
"sortablejs": "1.15.5", "sortablejs": "1.15.6",
"swagger-ui-dist": "5.17.14", "swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",

View file

@ -10,6 +10,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"image"
"image/color"
"image/png"
"math/big" "math/big"
"net/http" "net/http"
"net/url" "net/url"
@ -31,6 +34,8 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "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/container"
"code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
@ -42,6 +47,7 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/templates/vars" "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)) 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 // UpdateIssueTitle change issue's title
func UpdateIssueTitle(ctx *context.Context) { func UpdateIssueTitle(ctx *context.Context) {
issue := GetActionIssue(ctx) issue := GetActionIssue(ctx)

View file

@ -1146,6 +1146,7 @@ func registerRoutes(m *web.Route) {
m.Group("/{type:issues|pulls}", func() { m.Group("/{type:issues|pulls}", func() {
m.Group("/{index}", func() { m.Group("/{index}", func() {
m.Get("/info", repo.GetIssueInfo) m.Get("/info", repo.GetIssueInfo)
m.Get("/summary-card", repo.GetSummaryCard)
}) })
}) })
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication }, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication

View file

@ -14,6 +14,10 @@
{{if .Issue.Content}} {{if .Issue.Content}}
<meta property="og:description" content="{{StringUtils.EllipsisString .Issue.Content 300}}"> <meta property="og:description" content="{{StringUtils.EllipsisString .Issue.Content 300}}">
{{end}} {{end}}
<meta property="og:image" content="{{.Issue.SummaryCardURL}}">
<meta property="og:image:width" content="{{.Issue.SummaryCardWidth}}">
<meta property="og:image:height" content="{{.Issue.SummaryCardHeight}}">
<meta property="og:image:alt" content="{{ctx.Locale.Tr "repo.issues.summary_card_alt" .Issue.Title .Repository.FullName}}">
{{else if or .PageIsDiff .IsViewFile}} {{else if or .PageIsDiff .IsViewFile}}
<meta property="og:title" content="{{.Title}}"> <meta property="og:title" content="{{.Title}}">
<meta property="og:url" content="{{AppUrl}}{{.Link}}"> <meta property="og:url" content="{{AppUrl}}{{.Link}}">
@ -38,11 +42,13 @@
{{end}} {{end}}
{{end}} {{end}}
<meta property="og:type" content="object"> <meta property="og:type" content="object">
{{if not .Issue}}
{{if (.Repository.AvatarLink ctx)}} {{if (.Repository.AvatarLink ctx)}}
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}"> <meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
{{else}} {{else}}
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}"> <meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
{{end}} {{end}}
{{end}}
{{else}} {{else}}
<meta property="og:title" content="{{AppDisplayName}}"> <meta property="og:title" content="{{AppDisplayName}}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">

View file

@ -4,8 +4,8 @@
package integration package integration
import ( import (
"image"
"net/http" "net/http"
"strings"
"testing" "testing"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -13,6 +13,7 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestOpenGraphProperties(t *testing.T) { func TestOpenGraphProperties(t *testing.T) {
@ -43,7 +44,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "User Thirty", "og:title": "User Thirty",
"og:url": setting.AppURL + "user30", "og:url": setting.AppURL + "user30",
"og:type": "profile", "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, "og:site_name": siteName,
}, },
}, },
@ -55,7 +56,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "the_34-user.with.all.allowedChars", "og:url": setting.AppURL + "the_34-user.with.all.allowedChars",
"og:description": "some [commonmark](https://commonmark.org/)!", "og:description": "some [commonmark](https://commonmark.org/)!",
"og:type": "profile", "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, "og:site_name": siteName,
}, },
}, },
@ -67,7 +68,10 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "user2/repo1/issues/1", "og:url": setting.AppURL + "user2/repo1/issues/1",
"og:description": "content for the first issue", "og:description": "content for the first issue",
"og:type": "object", "og:type": "object",
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f", "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, "og:site_name": siteName,
}, },
}, },
@ -79,7 +83,10 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "user2/repo1/pulls/2", "og:url": setting.AppURL + "user2/repo1/pulls/2",
"og:description": "content for the second issue", "og:description": "content for the second issue",
"og:type": "object", "og:type": "object",
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f", "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, "og:site_name": siteName,
}, },
}, },
@ -90,7 +97,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "repo49/test/test.txt at master", "og:title": "repo49/test/test.txt at master",
"og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt", "og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt",
"og:type": "object", "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, "og:site_name": siteName,
}, },
}, },
@ -101,7 +108,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "Page With Spaced Name", "og:title": "Page With Spaced Name",
"og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name", "og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
"og:type": "object", "og:type": "object",
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f", "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
"og:site_name": siteName, "og:site_name": siteName,
}, },
}, },
@ -112,7 +119,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:title": "repo1", "og:title": "repo1",
"og:url": setting.AppURL + "user2/repo1", "og:url": setting.AppURL + "user2/repo1",
"og:type": "object", "og:type": "object",
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f", "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
"og:site_name": siteName, "og:site_name": siteName,
}, },
}, },
@ -124,7 +131,7 @@ func TestOpenGraphProperties(t *testing.T) {
"og:url": setting.AppURL + "user27/repo49", "og:url": setting.AppURL + "user27/repo49",
"og:description": "A wonderful repository with more than just a README.md", "og:description": "A wonderful repository with more than just a README.md",
"og:type": "object", "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, "og:site_name": siteName,
}, },
}, },
@ -132,6 +139,8 @@ func TestOpenGraphProperties(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", tc.url) req := NewRequest(t, "GET", tc.url)
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body) doc := NewHTMLParser(t, resp.Body)
@ -142,10 +151,6 @@ func TestOpenGraphProperties(t *testing.T) {
assert.True(t, foundProp) assert.True(t, foundProp)
content, foundContent := selection.Attr("content") content, foundContent := selection.Attr("content")
assert.True(t, foundContent, "opengraph meta tag without a content property") 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 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())
})
}
}