mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-27 06:03:51 +03:00
Merge branch 'forgejo' into forgejo
This commit is contained in:
commit
75f703326f
12 changed files with 892 additions and 32 deletions
1
go.mod
1
go.mod
|
@ -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
2
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/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=
|
||||||
|
|
|
@ -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
323
modules/card/card.go
Normal 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
244
modules/card/card_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
8
package-lock.json
generated
|
@ -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": {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,10 +42,12 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<meta property="og:type" content="object">
|
<meta property="og:type" content="object">
|
||||||
{{if (.Repository.AvatarLink ctx)}}
|
{{if not .Issue}}
|
||||||
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
|
{{if (.Repository.AvatarLink ctx)}}
|
||||||
{{else}}
|
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
|
||||||
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
|
{{else}}
|
||||||
|
<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}}">
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -63,24 +64,30 @@ func TestOpenGraphProperties(t *testing.T) {
|
||||||
name: "issue",
|
name: "issue",
|
||||||
url: "/user2/repo1/issues/1",
|
url: "/user2/repo1/issues/1",
|
||||||
expected: map[string]string{
|
expected: map[string]string{
|
||||||
"og:title": "issue1",
|
"og:title": "issue1",
|
||||||
"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:site_name": siteName,
|
"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",
|
name: "pull request",
|
||||||
url: "/user2/repo1/pulls/2",
|
url: "/user2/repo1/pulls/2",
|
||||||
expected: map[string]string{
|
expected: map[string]string{
|
||||||
"og:title": "issue2",
|
"og:title": "issue2",
|
||||||
"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:site_name": siteName,
|
"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: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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue