mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-20 20:07:03 +01: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/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
github.com/google/go-github/v64 v64.0.0
|
||||
github.com/google/pprof v0.0.0-20241017200806-017d972448fc
|
||||
github.com/google/uuid v1.6.0
|
||||
|
|
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/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
|
|
|
@ -411,6 +411,25 @@ func (issue *Issue) HTMLURL() string {
|
|||
return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index)
|
||||
}
|
||||
|
||||
// SummaryCardURL returns the absolute URL to an image providing a summary of the issue
|
||||
func (issue *Issue) SummaryCardURL() string {
|
||||
return fmt.Sprintf("%s/summary-card", issue.HTMLURL())
|
||||
}
|
||||
|
||||
func (issue *Issue) SummaryCardSize() (int, int) {
|
||||
return 1200, 600
|
||||
}
|
||||
|
||||
func (issue *Issue) SummaryCardWidth() int {
|
||||
width, _ := issue.SummaryCardSize()
|
||||
return width
|
||||
}
|
||||
|
||||
func (issue *Issue) SummaryCardHeight() int {
|
||||
_, height := issue.SummaryCardSize()
|
||||
return height
|
||||
}
|
||||
|
||||
// Link returns the issue's relative URL.
|
||||
func (issue *Issue) Link() string {
|
||||
var path string
|
||||
|
|
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.num_comments_1 = %d comment
|
||||
issues.num_comments = %d comments
|
||||
issues.num_reviews_one = %d review
|
||||
issues.num_reviews_few = %d reviews
|
||||
issues.commented_at = `commented <a href="#%s">%s</a>`
|
||||
issues.delete_comment_confirm = Are you sure you want to delete this comment?
|
||||
issues.context.copy_link = Copy link
|
||||
|
@ -1831,6 +1833,7 @@ issues.content_history.options = Options
|
|||
issues.reference_link = Reference: %s
|
||||
issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner.
|
||||
issues.comment.blocked_by_user = You cannot create a comment on this issue because you are blocked by the repository owner or the poster of the issue.
|
||||
issues.summary_card_alt = Summary card of an issue titled "%s" in repository %s
|
||||
|
||||
compare.compare_base = base
|
||||
compare.compare_head = compare
|
||||
|
|
8
package-lock.json
generated
8
package-lock.json
generated
|
@ -41,7 +41,7 @@
|
|||
"postcss-loader": "8.1.1",
|
||||
"postcss-nesting": "13.0.1",
|
||||
"pretty-ms": "9.0.0",
|
||||
"sortablejs": "1.15.5",
|
||||
"sortablejs": "1.15.6",
|
||||
"swagger-ui-dist": "5.17.14",
|
||||
"tailwindcss": "3.4.15",
|
||||
"throttle-debounce": "5.0.0",
|
||||
|
@ -14596,9 +14596,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.5.tgz",
|
||||
"integrity": "sha512-xDJLosRJzZ+nVnjaUYmO9H/wZth0lWTRq7VzV1eQyDSKsvxmoJ69HTGcwnwGYpJG/AkJ9OWiwWH4BhIycdonWw==",
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
|
||||
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-list-map": {
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
"postcss-loader": "8.1.1",
|
||||
"postcss-nesting": "13.0.1",
|
||||
"pretty-ms": "9.0.0",
|
||||
"sortablejs": "1.15.5",
|
||||
"sortablejs": "1.15.6",
|
||||
"swagger-ui-dist": "5.17.14",
|
||||
"tailwindcss": "3.4.15",
|
||||
"throttle-debounce": "5.0.0",
|
||||
|
|
|
@ -10,6 +10,9 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -31,6 +34,8 @@ import (
|
|||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
"code.gitea.io/gitea/modules/card"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
|
@ -42,6 +47,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/optional"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/templates/vars"
|
||||
|
@ -2212,6 +2218,222 @@ func GetIssueInfo(ctx *context.Context) {
|
|||
ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue))
|
||||
}
|
||||
|
||||
// GetSummaryCard get an issue of a repository
|
||||
func GetSummaryCard(ctx *context.Context) {
|
||||
issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.Error(http.StatusNotFound)
|
||||
} else {
|
||||
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
|
||||
ctx.Error(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cache := cache.GetCache()
|
||||
cacheKey := fmt.Sprintf("summary_card:issue:%s:%d", ctx.Locale.Language(), issue.ID)
|
||||
pngData, ok := cache.Get(cacheKey).([]byte)
|
||||
if ok && pngData != nil && len(pngData) > 0 {
|
||||
ctx.Resp.Header().Set("Content-Type", "image/png")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, err = ctx.Resp.Write(pngData)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSummaryCard", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
card, err := drawSummaryCard(ctx, issue)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSummaryCard", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Encode image, store in cache
|
||||
var imageBuffer bytes.Buffer
|
||||
err = png.Encode(&imageBuffer, card.Img)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSummaryCard", err)
|
||||
return
|
||||
}
|
||||
imageBytes := imageBuffer.Bytes()
|
||||
err = cache.Put(cacheKey, imageBytes, setting.CacheService.TTLSeconds())
|
||||
if err != nil {
|
||||
// don't abort serving the image if we just had a cache storage failure
|
||||
log.Warn("failed to cache issue summary card: %v", err)
|
||||
}
|
||||
|
||||
// Finish the uncached image response
|
||||
ctx.Resp.Header().Set("Content-Type", "image/png")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, err = ctx.Resp.Write(imageBytes)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetSummaryCard", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func drawSummaryCard(ctx *context.Context, issue *issues_model.Issue) (*card.Card, error) {
|
||||
width, height := issue.SummaryCardSize()
|
||||
mainCard, err := card.NewCard(width, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mainCard.SetMargin(60)
|
||||
topSection, bottomSection := mainCard.Split(false, 75)
|
||||
issueSummary, issueIcon := topSection.Split(true, 80)
|
||||
repoInfo, issueDescription := issueSummary.Split(false, 15)
|
||||
|
||||
repoInfo.SetMargin(10)
|
||||
_, err = repoInfo.DrawText(fmt.Sprintf("%s - #%d", issue.Repo.FullName(), issue.Index), color.Gray{128}, 36, card.Top, card.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issueDescription.SetMargin(10)
|
||||
_, err = issueDescription.DrawText(issue.Title, color.Black, 56, card.Top, card.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issueIcon.SetMargin(10)
|
||||
|
||||
repoAvatarPath := issue.Repo.CustomAvatarRelativePath()
|
||||
if repoAvatarPath != "" {
|
||||
repoAvatarFile, err := storage.RepoAvatars.Open(repoAvatarPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoAvatarImage, _, err := image.Decode(repoAvatarFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issueIcon.DrawImage(repoAvatarImage)
|
||||
} else {
|
||||
// If the repo didn't have an avatar, fallback to the repo owner's avatar for the right-hand-side icon
|
||||
err = issue.Repo.LoadOwner(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issue.Repo.Owner != nil {
|
||||
err = drawUser(ctx, issueIcon, issue.Repo.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issueStats, issueAttribution := bottomSection.Split(false, 50)
|
||||
|
||||
var state string
|
||||
if issue.IsPull && issue.PullRequest.HasMerged {
|
||||
if issue.PullRequest.Status == 3 {
|
||||
state = ctx.Locale.TrString("repo.pulls.manually_merged")
|
||||
} else {
|
||||
state = ctx.Locale.TrString("repo.pulls.merged")
|
||||
}
|
||||
} else if issue.IsClosed {
|
||||
state = ctx.Locale.TrString("repo.issues.closed_title")
|
||||
} else if issue.IsPull {
|
||||
if issue.PullRequest.IsWorkInProgress(ctx) {
|
||||
state = ctx.Locale.TrString("repo.issues.draft_title")
|
||||
} else {
|
||||
state = ctx.Locale.TrString("repo.issues.open_title")
|
||||
}
|
||||
} else {
|
||||
state = ctx.Locale.TrString("repo.issues.open_title")
|
||||
}
|
||||
state = strings.ToLower(state)
|
||||
|
||||
issueStats.SetMargin(10)
|
||||
if issue.IsPull {
|
||||
reviews := map[int64]bool{}
|
||||
for _, comment := range issue.Comments {
|
||||
if comment.Review != nil {
|
||||
reviews[comment.Review.ID] = true
|
||||
}
|
||||
}
|
||||
_, err = issueStats.DrawText(
|
||||
fmt.Sprintf("%s, %s, %s",
|
||||
ctx.Locale.TrN(
|
||||
issue.NumComments,
|
||||
"repo.issues.num_comments_1",
|
||||
"repo.issues.num_comments",
|
||||
issue.NumComments,
|
||||
),
|
||||
ctx.Locale.TrN(
|
||||
len(reviews),
|
||||
"repo.issues.num_reviews_one",
|
||||
"repo.issues.num_reviews_few",
|
||||
len(reviews),
|
||||
),
|
||||
state,
|
||||
),
|
||||
color.Gray{128}, 36, card.Top, card.Left)
|
||||
} else {
|
||||
_, err = issueStats.DrawText(
|
||||
fmt.Sprintf("%s, %s",
|
||||
ctx.Locale.TrN(
|
||||
issue.NumComments,
|
||||
"repo.issues.num_comments_1",
|
||||
"repo.issues.num_comments",
|
||||
issue.NumComments,
|
||||
),
|
||||
state,
|
||||
),
|
||||
color.Gray{128}, 36, card.Top, card.Left)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issueAttributionIcon, issueAttributionText := issueAttribution.Split(true, 8)
|
||||
issueAttributionText.SetMargin(5)
|
||||
_, err = issueAttributionText.DrawText(
|
||||
fmt.Sprintf(
|
||||
"%s - %s",
|
||||
issue.Poster.Name,
|
||||
issue.Created.AsTime().Format("2006-01-02"),
|
||||
),
|
||||
color.Gray{128}, 36, card.Middle, card.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = drawUser(ctx, issueAttributionIcon, issue.Poster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mainCard, nil
|
||||
}
|
||||
|
||||
func drawUser(ctx *context.Context, card *card.Card, user *user_model.User) error {
|
||||
if user.UseCustomAvatar {
|
||||
posterAvatarPath := user.CustomAvatarRelativePath()
|
||||
if posterAvatarPath != "" {
|
||||
userAvatarFile, err := storage.Avatars.Open(user.CustomAvatarRelativePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userAvatarImage, _, err := image.Decode(userAvatarFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
card.DrawImage(userAvatarImage)
|
||||
}
|
||||
} else {
|
||||
posterAvatarLink := user.AvatarLinkWithSize(ctx, 256)
|
||||
card.DrawExternalImage(posterAvatarLink)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIssueTitle change issue's title
|
||||
func UpdateIssueTitle(ctx *context.Context) {
|
||||
issue := GetActionIssue(ctx)
|
||||
|
|
|
@ -1146,6 +1146,7 @@ func registerRoutes(m *web.Route) {
|
|||
m.Group("/{type:issues|pulls}", func() {
|
||||
m.Group("/{index}", func() {
|
||||
m.Get("/info", repo.GetIssueInfo)
|
||||
m.Get("/summary-card", repo.GetSummaryCard)
|
||||
})
|
||||
})
|
||||
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
{{if .Issue.Content}}
|
||||
<meta property="og:description" content="{{StringUtils.EllipsisString .Issue.Content 300}}">
|
||||
{{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}}
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
<meta property="og:url" content="{{AppUrl}}{{.Link}}">
|
||||
|
@ -38,10 +42,12 @@
|
|||
{{end}}
|
||||
{{end}}
|
||||
<meta property="og:type" content="object">
|
||||
{{if (.Repository.AvatarLink ctx)}}
|
||||
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
|
||||
{{else}}
|
||||
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
|
||||
{{if not .Issue}}
|
||||
{{if (.Repository.AvatarLink ctx)}}
|
||||
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
|
||||
{{else}}
|
||||
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<meta property="og:title" content="{{AppDisplayName}}">
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"image"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenGraphProperties(t *testing.T) {
|
||||
|
@ -43,7 +44,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:title": "User Thirty",
|
||||
"og:url": setting.AppURL + "user30",
|
||||
"og:type": "profile",
|
||||
"og:image": "http://localhost:3003/assets/img/avatar_default.png",
|
||||
"og:image": setting.AppURL + "assets/img/avatar_default.png",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -55,7 +56,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:url": setting.AppURL + "the_34-user.with.all.allowedChars",
|
||||
"og:description": "some [commonmark](https://commonmark.org/)!",
|
||||
"og:type": "profile",
|
||||
"og:image": "http://localhost:3003/assets/img/avatar_default.png",
|
||||
"og:image": setting.AppURL + "assets/img/avatar_default.png",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -63,24 +64,30 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
name: "issue",
|
||||
url: "/user2/repo1/issues/1",
|
||||
expected: map[string]string{
|
||||
"og:title": "issue1",
|
||||
"og:url": setting.AppURL + "user2/repo1/issues/1",
|
||||
"og:description": "content for the first issue",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:site_name": siteName,
|
||||
"og:title": "issue1",
|
||||
"og:url": setting.AppURL + "user2/repo1/issues/1",
|
||||
"og:description": "content for the first issue",
|
||||
"og:type": "object",
|
||||
"og:image": setting.AppURL + "user2/repo1/issues/1/summary-card",
|
||||
"og:image:alt": "Summary card of an issue titled \"issue1\" in repository user2/repo1",
|
||||
"og:image:width": "1200",
|
||||
"og:image:height": "600",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pull request",
|
||||
url: "/user2/repo1/pulls/2",
|
||||
expected: map[string]string{
|
||||
"og:title": "issue2",
|
||||
"og:url": setting.AppURL + "user2/repo1/pulls/2",
|
||||
"og:description": "content for the second issue",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:site_name": siteName,
|
||||
"og:title": "issue2",
|
||||
"og:url": setting.AppURL + "user2/repo1/pulls/2",
|
||||
"og:description": "content for the second issue",
|
||||
"og:type": "object",
|
||||
"og:image": setting.AppURL + "user2/repo1/pulls/2/summary-card",
|
||||
"og:image:alt": "Summary card of an issue titled \"issue2\" in repository user2/repo1",
|
||||
"og:image:width": "1200",
|
||||
"og:image:height": "600",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -90,7 +97,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:title": "repo49/test/test.txt at master",
|
||||
"og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/assets/img/avatar_default.png",
|
||||
"og:image": setting.AppURL + "assets/img/avatar_default.png",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -101,7 +108,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:title": "Page With Spaced Name",
|
||||
"og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -112,7 +119,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:title": "repo1",
|
||||
"og:url": setting.AppURL + "user2/repo1",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -124,7 +131,7 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
"og:url": setting.AppURL + "user27/repo49",
|
||||
"og:description": "A wonderful repository with more than just a README.md",
|
||||
"og:type": "object",
|
||||
"og:image": "http://localhost:3003/assets/img/avatar_default.png",
|
||||
"og:image": setting.AppURL + "assets/img/avatar_default.png",
|
||||
"og:site_name": siteName,
|
||||
},
|
||||
},
|
||||
|
@ -132,6 +139,8 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", tc.url)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
doc := NewHTMLParser(t, resp.Body)
|
||||
|
@ -142,10 +151,6 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
assert.True(t, foundProp)
|
||||
content, foundContent := selection.Attr("content")
|
||||
assert.True(t, foundContent, "opengraph meta tag without a content property")
|
||||
if prop == "og:image" {
|
||||
content = strings.ReplaceAll(content, "http://localhost:3001", "http://localhost:3003")
|
||||
content = strings.ReplaceAll(content, "http://localhost:3002", "http://localhost:3003")
|
||||
}
|
||||
foundProps[prop] = content
|
||||
})
|
||||
|
||||
|
@ -153,3 +158,37 @@ func TestOpenGraphProperties(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGraphSummaryCard(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "issue",
|
||||
url: "/user2/repo1/issues/1/summary-card",
|
||||
},
|
||||
{
|
||||
name: "pull request",
|
||||
url: "/user2/repo1/pulls/2/summary-card",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", tc.url)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
assert.Equal(t, "image/png", resp.Header().Get("Content-Type"))
|
||||
img, imgType, err := image.Decode(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "png", imgType)
|
||||
assert.Equal(t, 1200, img.Bounds().Dx())
|
||||
assert.Equal(t, 600, img.Bounds().Dy())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue