mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-23 06:43:34 +01:00
Add a "summary card" to issues & PRs for consumption by OpenGraph clients (#6053)
Some checks are pending
/ release (push) Waiting to run
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Some checks are pending
/ release (push) Waiting to run
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
## Overview Hi all, I'm a first-time contributor to Forgejo. I was looking for something interesting to contribute and the first thing that caught my attention was https://codeberg.org/forgejo/forgejo/issues/6043, a request for an enhancement to include "issue previews" when publishing links to social media platforms. As a bit of background, the way these platforms work is that they search for meta tags in the posted link's content, and if they find a meta `og:image` (along with other meta tags) they'll pull the image to include in the social media post. Forgejo currently provides an `og:image` tag but it just renders the repository or repository-owner's avatar. This PR will render `og:image` for an issue or PR into a link to `{...}/summary-card`, which is a dynamically generated image that contains a summary of the issue. ## Design Notes ### Rendering / Rasterization The tricky part of solving this problem is rendering an image that combines some text, some images, and some layout elements. To address this, I've created a `card` module which allows for a handful of operations: - Create a new rendered image (a "Card") - Add a margin to a card - Split the card, horizontally or vertically, into two pieces with a proportional layout (eg. 70%/30%, as desired), each of which are "Cards" that render into the same root image - Render text into a card, with line-wrapping and text-alignment capabilities - Render an image onto a card - Fetches an external image as safely as possible (for server-side fetch of Gravatar, etc.) The card module can be reused to create `og:image` summary cards for any object in the future, although obviously it's capabilities are limited. The current implementation is on issues/PRs. I considered a few alternative approaches before taking this approach, and here's why I rejected those options: - Provide the summary card as an SVG object which could be rendered much more easily with a template file -- however, support for SVG isn't defined as positive for OpenGraph, and a quick look through some existing implementations suggest that it is not widely supported, if at all - Rendering as HTML/CSS, or SVG, and then using an external tool to convert into a PNG (or other static) image -- this would be much nicer and easier to implement, but would require tying in some very heavy-weight dependencies - Rendering using a more sophisticated graphics library, eg. cairo -- also would be nicer and easier to implement, but again a heavy dependency for a small functionality As a result of the limited capabilities of the new card module, summary cards don't have icons on them (which would require SVG rasterization) or pretty status badges with colors and rounded rects. In the future if better drawing capabilities were added, the graphics could be improved, but it doesn't seem too important. ### External Avatars In order to rasterize a user's avatar onto the summary card, it might have to be retrieved by the server from the external source (eg. Gravatar). A `fetchExternalImage` routine attempts to do this in the safest way possible to protect the server from any possible security exposure from this; (a) verifying that the content-types are acceptable, (b) ensuring that the file-size and image-size are within the safe bounds that are used for custom avatars, (c) using a very-short timeout to avoid stalling the server if an external dependency is offline. ### Caching Summary cards are cached after rendered. This has the downside of causing updates to statuses, avatars, titles, etc. being stale on the summary card for the cache TTL. However, during testing I found that some social media engines like Mastodon will cause the summary card to be accessed a significant number of times after being referenced by a post, causing a mini-tornado of requests. The cache compensates for this to avoid server load in this situation. ### Scope I'm considering out-of-scope: - Summary cards on other objects (eg. repos, users) can be left for future implementation ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - ~~I added test coverage for JavaScript changes...~~ n/a, no JS changes - [x] ~~in `web_src/js/*.test.js` if it can be unit tested.~~ - [x] ~~in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).~~ - Manual testing - [x] Access & attach screenshots of both an issue and a pull-request's summary card; see below - [x] Ensure reasonable (non-crash) behavior of rendering text with glyphs outside the font -- correctly rendered as replacement unicode chars - [x] Using a public test instance, verify that og:image behavior looks good on platforms like Mastodon and BlueSky - [x] Bluesky: ✅ - [x] Mastodon: ✅ (Note that the summary card will be requested many times as the post is federated; either each server, or each client, will fetch it itself) - [x] OpenGraph test site (https://www.opengraph.xyz/): ✅ - [x] Discord: Looks OK ✅; needs "twitter:card" to be set to "summary_large_image" to display the large-scale image, but (a) that's probably annoying to use, (b) probably wrong because it doesn't match Twitter Card's spec for a "photographic image", and (c) don't want to encourage/continue use of vendor-specific tag - [x] Verify cases with user avatar missing (or autogen), and repo avatar missing (falls back to repo owner avatar) Pull request summary card: ![image](/attachments/b64283e3-9a3c-4f19-9d00-961662ffe86b) Issue summary card: ![image](/attachments/318ce589-02e0-493e-b10c-5b2cb2627db2) (images to the right are the custom repo avatar, w/ fallback to the repo owner avatar) ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. - OpenGraph capabilities are expected to work in the background without user awareness, and so there is no need for documentation to explain the capabilities for users. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6053 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
parent
0c2b01dbb4
commit
77fafbe578
10 changed files with 887 additions and 27 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
|
||||||
|
|
|
@ -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