diff --git a/models/issues/issue.go b/models/issues/issue.go
index 17391ffe6c..fbbc4828a2 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -416,20 +416,6 @@ func (issue *Issue) SummaryCardURL() string {
return fmt.Sprintf("%s/summary-card", issue.HTMLURL())
}
-func (issue *Issue) SummaryCardSize() (int, int) {
- return 1200, 600
-}
-
-func (issue *Issue) SummaryCardWidth() int {
- width, _ := issue.SummaryCardSize()
- return width
-}
-
-func (issue *Issue) SummaryCardHeight() int {
- _, height := issue.SummaryCardSize()
- return height
-}
-
// Link returns the issue's relative URL.
func (issue *Issue) Link() string {
var path string
diff --git a/models/repo/release.go b/models/repo/release.go
index 38e38c6572..eb18f8aa02 100644
--- a/models/repo/release.go
+++ b/models/repo/release.go
@@ -97,13 +97,11 @@ func init() {
// LoadAttributes load repo and publisher attributes for a release
func (r *Release) LoadAttributes(ctx context.Context) error {
- var err error
- if r.Repo == nil {
- r.Repo, err = GetRepositoryByID(ctx, r.RepoID)
- if err != nil {
- return err
- }
+ err := r.LoadRepo(ctx)
+ if err != nil {
+ return err
}
+
if r.Publisher == nil {
r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID)
if err != nil {
@@ -123,6 +121,18 @@ func (r *Release) LoadAttributes(ctx context.Context) error {
return GetReleaseAttachments(ctx, r)
}
+// LoadRepo load repo attribute for release
+func (r *Release) LoadRepo(ctx context.Context) error {
+ if r.Repo != nil {
+ return nil
+ }
+
+ var err error
+ r.Repo, err = GetRepositoryByID(ctx, r.RepoID)
+
+ return err
+}
+
// LoadArchiveDownloadCount loads the download count for the source archives
func (r *Release) LoadArchiveDownloadCount(ctx context.Context) error {
var err error
@@ -130,6 +140,25 @@ func (r *Release) LoadArchiveDownloadCount(ctx context.Context) error {
return err
}
+// GetTotalDownloadCount returns the summary of all dowload count of files attached to the release
+func (r *Release) GetTotalDownloadCount(ctx context.Context) (int64, error) {
+ var archiveCount int64
+ if !r.HideArchiveLinks {
+ _, err := db.GetEngine(ctx).SQL("SELECT SUM(count) FROM repo_archive_download_count WHERE release_id = ?", r.ID).Get(&archiveCount)
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ var attachmentCount int64
+ _, err := db.GetEngine(ctx).SQL("SELECT SUM(download_count) FROM attachment WHERE release_id = ?", r.ID).Get(&attachmentCount)
+ if err != nil {
+ return 0, err
+ }
+
+ return archiveCount + attachmentCount, nil
+}
+
// APIURL the api url for a release. release must have attributes loaded
func (r *Release) APIURL() string {
return r.Repo.APIURL() + "/releases/" + strconv.FormatInt(r.ID, 10)
@@ -160,6 +189,20 @@ func (r *Release) Link() string {
return r.Repo.Link() + "/releases/tag/" + util.PathEscapeSegments(r.TagName)
}
+// SummaryCardURL returns the absolute URL to an image providing a summary of the release
+func (r *Release) SummaryCardURL() string {
+ return fmt.Sprintf("%s/releases/summary-card/%s", r.Repo.HTMLURL(), util.PathEscapeSegments(r.TagName))
+}
+
+// DisplayName retruns the name of the release
+func (r *Release) DisplayName() string {
+ if r.IsTag && r.Title == "" {
+ return r.TagName
+ }
+
+ return r.Title
+}
+
// IsReleaseExist returns true if release with given tag name already exists.
func IsReleaseExist(ctx context.Context, repoID int64, tagName string) (bool, error) {
if len(tagName) == 0 {
diff --git a/models/repo/release_test.go b/models/repo/release_test.go
index 4e61a2805d..7680f2cc9d 100644
--- a/models/repo/release_test.go
+++ b/models/repo/release_test.go
@@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,3 +26,26 @@ func TestMigrate_InsertReleases(t *testing.T) {
err := InsertReleases(db.DefaultContext, r)
require.NoError(t, err)
}
+
+func TestReleaseLoadRepo(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ release := unittest.AssertExistsAndLoadBean(t, &Release{ID: 1})
+ assert.Nil(t, release.Repo)
+
+ require.NoError(t, release.LoadRepo(db.DefaultContext))
+
+ assert.EqualValues(t, 1, release.Repo.ID)
+}
+
+func TestReleaseDisplayName(t *testing.T) {
+ release := Release{TagName: "TagName"}
+
+ assert.Empty(t, release.DisplayName())
+
+ release.IsTag = true
+ assert.Equal(t, "TagName", release.DisplayName())
+
+ release.Title = "Title"
+ assert.Equal(t, "Title", release.DisplayName())
+}
diff --git a/models/repo/repo.go b/models/repo/repo.go
index cd6be48b90..bdf0de2f85 100644
--- a/models/repo/repo.go
+++ b/models/repo/repo.go
@@ -327,6 +327,11 @@ func (repo *Repository) HTMLURL() string {
return setting.AppURL + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
}
+// SummaryCardURL returns the absolute URL to an image providing a summary of the repo
+func (repo *Repository) SummaryCardURL() string {
+ return fmt.Sprintf("%s/-/summary-card", repo.HTMLURL())
+}
+
// CommitLink make link to by commit full ID
// note: won't check whether it's an right id
func (repo *Repository) CommitLink(commitID string) (result string) {
diff --git a/modules/card/card.go b/modules/card/card.go
index bb160d7ea3..370d241073 100644
--- a/modules/card/card.go
+++ b/modules/card/card.go
@@ -5,6 +5,7 @@ package card
import (
"bytes"
+ "fmt"
"image"
"image/color"
"io"
@@ -35,12 +36,19 @@ type Card struct {
Img *image.RGBA
Font *truetype.Font
Margin int
+ Width int
+ Height int
}
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
return truetype.Parse(goregular.TTF)
})
+// DefaultSize returns the default size for a card
+func DefaultSize() (int, int) {
+ return 1200, 600
+}
+
// 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))
@@ -55,6 +63,8 @@ func NewCard(width, height int) (*Card, error) {
Img: img,
Font: font,
Margin: 0,
+ Width: width,
+ Height: height,
}, nil
}
@@ -67,14 +77,14 @@ func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
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}
+ return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
+ &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
}
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}
+ return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
+ &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
}
// SetMargin sets the margins for the card
@@ -244,9 +254,14 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
},
}
+ // Go expects a absolute URL, so we must change a relative to an absolute one
+ if !strings.Contains(url, "://") {
+ url = fmt.Sprintf("%s%s", setting.AppURL, strings.TrimPrefix(url, "/"))
+ }
+
resp, err := client.Get(url)
if err != nil {
- log.Warn("error when fetching external image from %s: %w", url, err)
+ log.Warn("error when fetching external image from %s: %v", url, err)
return nil, false
}
defer resp.Body.Close()
@@ -321,3 +336,8 @@ func (c *Card) DrawExternalImage(url string) {
}
c.DrawImage(image)
}
+
+// DrawRect draws a rect with the given color
+func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
+ draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f9cb88eaf4..97d33f9e40 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1155,6 +1155,7 @@ blame_prior = View blame prior to this change
blame.ignore_revs = Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.
blame.ignore_revs.failed = Failed to ignore revisions in .git-blame-ignore-revs.
author_search_tooltip = Shows a maximum of 30 users
+summary_card_alt = Summary card of repository %s
tree_path_not_found_commit = Path %[1]s doesn't exist in commit %[2]s
tree_path_not_found_branch = Path %[1]s doesn't exist in branch %[2]s
@@ -2755,6 +2756,7 @@ release.asset_name = Asset name
release.asset_external_url = External URL
release.add_external_asset = Add external asset
release.invalid_external_url = Invalid external URL: "%s"
+release.summary_card_alt = Summary card of an release titled "%s" in repository %s
branch.name = Branch name
branch.already_exists = A branch named "%s" already exists.
diff --git a/routers/web/repo/card.go b/routers/web/repo/card.go
new file mode 100644
index 0000000000..e73971cd94
--- /dev/null
+++ b/routers/web/repo/card.go
@@ -0,0 +1,526 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package repo
+
+import (
+ "bytes"
+ "encoding/hex"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/card"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/services/context"
+)
+
+// drawUser draws a user avatar in a summary card
+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
+}
+
+// drawRepoIcon draws the repo icon in a summary card
+func drawRepoIcon(ctx *context.Context, card *card.Card, repo *repo_model.Repository) error {
+ repoAvatarPath := repo.CustomAvatarRelativePath()
+
+ if repoAvatarPath != "" {
+ repoAvatarFile, err := storage.RepoAvatars.Open(repoAvatarPath)
+ if err != nil {
+ return err
+ }
+ repoAvatarImage, _, err := image.Decode(repoAvatarFile)
+ if err != nil {
+ return err
+ }
+ card.DrawImage(repoAvatarImage)
+ return nil
+ }
+
+ // If the repo didn't have an avatar, fallback to the repo owner's avatar for the right-hand-side icon
+ err := repo.LoadOwner(ctx)
+ if err != nil {
+ return err
+ }
+ if repo.Owner != nil {
+ err = drawUser(ctx, card, repo.Owner)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// hexToColor converts a hex color to a go color
+func hexToColor(colorStr string) (*color.RGBA, error) {
+ colorStr = strings.TrimLeft(colorStr, "#")
+
+ b, err := hex.DecodeString(colorStr)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(b) < 3 {
+ return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
+ }
+
+ color := color.RGBA{b[0], b[1], b[2], 255}
+
+ return &color, nil
+}
+
+func drawLanguagesCard(ctx *context.Context, card *card.Card) error {
+ languageList, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5)
+ if err != nil {
+ return err
+ }
+ if len(languageList) == 0 {
+ card.DrawRect(0, 0, card.Width, card.Height, color.White)
+ return nil
+ }
+
+ currentX := 0
+ var langColor *color.RGBA
+
+ for _, lang := range languageList {
+ langColor, err = hexToColor(lang.Color)
+ if err != nil {
+ return err
+ }
+
+ langWidth := float32(card.Width) * (lang.Percentage / 100)
+ card.DrawRect(currentX, 0, currentX+int(langWidth), card.Width, langColor)
+ currentX += int(langWidth)
+ }
+
+ if currentX < card.Width {
+ card.DrawRect(currentX, 0, card.Width, card.Height, langColor)
+ }
+
+ return nil
+}
+
+func drawRepoSummaryCard(ctx *context.Context, repo *repo_model.Repository) (*card.Card, error) {
+ width, height := card.DefaultSize()
+ mainCard, err := card.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ contentCard, languageBarCard := mainCard.Split(false, 90)
+
+ contentCard.SetMargin(60)
+ topSection, bottomSection := contentCard.Split(false, 75)
+ issueSummary, issueIcon := topSection.Split(true, 80)
+ repoInfo, issueDescription := issueSummary.Split(false, 30)
+
+ repoInfo.SetMargin(10)
+ _, err = repoInfo.DrawText(repo.FullName(), color.Black, 56, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ issueDescription.SetMargin(10)
+ _, err = issueDescription.DrawText(repo.Description, color.Gray{128}, 36, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ issueIcon.SetMargin(10)
+ err = drawRepoIcon(ctx, issueIcon, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ topCountCard, bottomCountCard := bottomSection.Split(false, 50)
+
+ releaseCount, err := db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
+ // only show draft releases for users who can write, read-only users shouldn't see draft releases.
+ IncludeDrafts: ctx.Repo.CanWrite(unit_model.TypeReleases),
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ starsText := ctx.Locale.TrN(
+ repo.NumStars,
+ "explore.stars_one",
+ "explore.stars_few",
+ repo.NumStars,
+ )
+ forksText := ctx.Locale.TrN(
+ repo.NumForks,
+ "explore.forks_one",
+ "explore.forks_few",
+ repo.NumForks,
+ )
+ releasesText := ctx.Locale.TrN(
+ releaseCount,
+ "repo.activity.title.releases_1",
+ "repo.activity.title.releases_n",
+ releaseCount,
+ )
+
+ topCountText := fmt.Sprintf("%s • %s • %s", starsText, forksText, releasesText)
+
+ topCountCard.SetMargin(10)
+ _, err = topCountCard.DrawText(topCountText, color.Gray{128}, 36, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ issuesText := ctx.Locale.TrN(
+ repo.NumOpenIssues,
+ "repo.activity.title.issues_1",
+ "repo.activity.title.issues_n",
+ repo.NumOpenIssues,
+ )
+ pullRequestsText := ctx.Locale.TrN(
+ repo.NumOpenPulls,
+ "repo.activity.title.prs_1",
+ "repo.activity.title.prs_n",
+ repo.NumOpenPulls,
+ )
+
+ bottomCountText := fmt.Sprintf("%s • %s", issuesText, pullRequestsText)
+
+ bottomCountCard.SetMargin(10)
+ _, err = bottomCountCard.DrawText(bottomCountText, color.Gray{128}, 36, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ err = drawLanguagesCard(ctx, languageBarCard)
+ if err != nil {
+ return nil, err
+ }
+
+ return mainCard, nil
+}
+
+func drawIssueSummaryCard(ctx *context.Context, issue *issue_model.Issue) (*card.Card, error) {
+ width, height := card.DefaultSize()
+ 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)
+ err = drawRepoIcon(ctx, issueIcon, issue.Repo)
+ 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(time.DateOnly),
+ ),
+ 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 drawReleaseSummaryCard(ctx *context.Context, release *repo_model.Release) (*card.Card, error) {
+ width, height := card.DefaultSize()
+ mainCard, err := card.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ mainCard.SetMargin(60)
+ topSection, bottomSection := mainCard.Split(false, 75)
+ releaseSummary, repoIcon := topSection.Split(true, 80)
+ repoInfo, releaseDescription := releaseSummary.Split(false, 15)
+
+ repoInfo.SetMargin(10)
+ _, err = repoInfo.DrawText(release.Repo.FullName(), color.Gray{128}, 36, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ releaseDescription.SetMargin(10)
+ _, err = releaseDescription.DrawText(release.DisplayName(), color.Black, 56, card.Top, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ repoIcon.SetMargin(10)
+ err = drawRepoIcon(ctx, repoIcon, release.Repo)
+ if err != nil {
+ return nil, err
+ }
+
+ downloadCountCard, releaseDateCard := bottomSection.Split(true, 75)
+
+ downloadCount, err := release.GetTotalDownloadCount(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ downloadCountText := ctx.Locale.TrN(
+ strconv.FormatInt(downloadCount, 10),
+ "repo.release.download_count_one",
+ "repo.release.download_count_few",
+ strconv.FormatInt(downloadCount, 10),
+ )
+
+ _, err = downloadCountCard.DrawText(string(downloadCountText), color.Gray{128}, 36, card.Bottom, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = releaseDateCard.DrawText(release.CreatedUnix.AsTime().Format(time.DateOnly), color.Gray{128}, 36, card.Bottom, card.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ return mainCard, nil
+}
+
+// checkCardCache checks if a card in cache and serves it
+func checkCardCache(ctx *context.Context, cacheKey string) bool {
+ cache := cache.GetCache()
+ 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 true
+ }
+
+ return false
+}
+
+// serveCard server a Card to the user adds it to the cache
+func serveCard(ctx *context.Context, card *card.Card, cacheKey string) {
+ cache := cache.GetCache()
+
+ // 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 DrawRepoSummaryCard(ctx *context.Context) {
+ cacheKey := fmt.Sprintf("summary_card:repo:%s:%d", ctx.Locale.Language(), ctx.Repo.Repository.ID)
+
+ if checkCardCache(ctx, cacheKey) {
+ return
+ }
+
+ card, err := drawRepoSummaryCard(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("drawRepoSummaryCar", err)
+ return
+ }
+
+ serveCard(ctx, card, cacheKey)
+}
+
+func DrawIssueSummaryCard(ctx *context.Context) {
+ issue, err := issue_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issue_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
+ }
+
+ cacheKey := fmt.Sprintf("summary_card:issue:%s:%d", ctx.Locale.Language(), issue.ID)
+
+ if checkCardCache(ctx, cacheKey) {
+ return
+ }
+
+ card, err := drawIssueSummaryCard(ctx, issue)
+ if err != nil {
+ ctx.ServerError("drawIssueSummaryCar", err)
+ return
+ }
+
+ serveCard(ctx, card, cacheKey)
+}
+
+func DrawReleaseSummaryCard(ctx *context.Context) {
+ release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, ctx.Params("*"))
+ if err != nil {
+ if repo_model.IsErrReleaseNotExist(err) {
+ ctx.NotFound("", nil)
+ } else {
+ ctx.ServerError("GetReleaseForRepoByID", err)
+ }
+ return
+ }
+
+ err = release.LoadRepo(ctx)
+ if err != nil {
+ ctx.ServerError("LoadRepo", err)
+ return
+ }
+
+ cacheKey := fmt.Sprintf("summary_card:release:%s:%d", ctx.Locale.Language(), release.ID)
+
+ if checkCardCache(ctx, cacheKey) {
+ return
+ }
+
+ card, err := drawReleaseSummaryCard(ctx, release)
+ if err != nil {
+ ctx.ServerError("drawRepoSummaryCar", err)
+ return
+ }
+
+ serveCard(ctx, card, cacheKey)
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index c154a9b665..779cbd4b6a 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -10,9 +10,6 @@ import (
"errors"
"fmt"
"html/template"
- "image"
- "image/color"
- "image/png"
"math/big"
"net/http"
"net/url"
@@ -34,8 +31,6 @@ 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"
@@ -47,7 +42,6 @@ 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"
@@ -2076,6 +2070,8 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["RefEndName"] = git.RefName(issue.Ref).ShortName()
ctx.Data["NewPinAllowed"] = pinAllowed
ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0
+ ctx.Data["OpenGraphImageURL"] = issue.SummaryCardURL()
+ ctx.Data["OpenGraphImageAltText"] = ctx.Tr("repo.issues.summary_card_alt", issue.Title, issue.Repo.FullName())
prepareHiddenCommentType(ctx)
if ctx.Written() {
@@ -2233,222 +2229,6 @@ func GetIssueInfo(ctx *context.Context) {
ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue))
}
-// GetSummaryCard get an issue of a repository
-func GetSummaryCard(ctx *context.Context) {
- issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
- if err != nil {
- if issues_model.IsErrIssueNotExist(err) {
- ctx.Error(http.StatusNotFound)
- } else {
- ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
- }
- return
- }
-
- if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
- ctx.Error(http.StatusNotFound)
- return
- }
-
- cache := cache.GetCache()
- cacheKey := fmt.Sprintf("summary_card:issue:%s:%d", ctx.Locale.Language(), issue.ID)
- pngData, ok := cache.Get(cacheKey).([]byte)
- if ok && pngData != nil && len(pngData) > 0 {
- ctx.Resp.Header().Set("Content-Type", "image/png")
- ctx.Resp.WriteHeader(http.StatusOK)
- _, err = ctx.Resp.Write(pngData)
- if err != nil {
- ctx.ServerError("GetSummaryCard", err)
- }
- return
- }
-
- card, err := drawSummaryCard(ctx, issue)
- if err != nil {
- ctx.ServerError("GetSummaryCard", err)
- return
- }
-
- // Encode image, store in cache
- var imageBuffer bytes.Buffer
- err = png.Encode(&imageBuffer, card.Img)
- if err != nil {
- ctx.ServerError("GetSummaryCard", err)
- return
- }
- imageBytes := imageBuffer.Bytes()
- err = cache.Put(cacheKey, imageBytes, setting.CacheService.TTLSeconds())
- if err != nil {
- // don't abort serving the image if we just had a cache storage failure
- log.Warn("failed to cache issue summary card: %v", err)
- }
-
- // Finish the uncached image response
- ctx.Resp.Header().Set("Content-Type", "image/png")
- ctx.Resp.WriteHeader(http.StatusOK)
- _, err = ctx.Resp.Write(imageBytes)
- if err != nil {
- ctx.ServerError("GetSummaryCard", err)
- return
- }
-}
-
-func drawSummaryCard(ctx *context.Context, issue *issues_model.Issue) (*card.Card, error) {
- width, height := issue.SummaryCardSize()
- mainCard, err := card.NewCard(width, height)
- if err != nil {
- return nil, err
- }
-
- mainCard.SetMargin(60)
- topSection, bottomSection := mainCard.Split(false, 75)
- issueSummary, issueIcon := topSection.Split(true, 80)
- repoInfo, issueDescription := issueSummary.Split(false, 15)
-
- repoInfo.SetMargin(10)
- _, err = repoInfo.DrawText(fmt.Sprintf("%s - #%d", issue.Repo.FullName(), issue.Index), color.Gray{128}, 36, card.Top, card.Left)
- if err != nil {
- return nil, err
- }
-
- issueDescription.SetMargin(10)
- _, err = issueDescription.DrawText(issue.Title, color.Black, 56, card.Top, card.Left)
- if err != nil {
- return nil, err
- }
-
- issueIcon.SetMargin(10)
-
- repoAvatarPath := issue.Repo.CustomAvatarRelativePath()
- if repoAvatarPath != "" {
- repoAvatarFile, err := storage.RepoAvatars.Open(repoAvatarPath)
- if err != nil {
- return nil, err
- }
- repoAvatarImage, _, err := image.Decode(repoAvatarFile)
- if err != nil {
- return nil, err
- }
- issueIcon.DrawImage(repoAvatarImage)
- } else {
- // If the repo didn't have an avatar, fallback to the repo owner's avatar for the right-hand-side icon
- err = issue.Repo.LoadOwner(ctx)
- if err != nil {
- return nil, err
- }
- if issue.Repo.Owner != nil {
- err = drawUser(ctx, issueIcon, issue.Repo.Owner)
- if err != nil {
- return nil, err
- }
- }
- }
-
- issueStats, issueAttribution := bottomSection.Split(false, 50)
-
- var state string
- if issue.IsPull && issue.PullRequest.HasMerged {
- if issue.PullRequest.Status == 3 {
- state = ctx.Locale.TrString("repo.pulls.manually_merged")
- } else {
- state = ctx.Locale.TrString("repo.pulls.merged")
- }
- } else if issue.IsClosed {
- state = ctx.Locale.TrString("repo.issues.closed_title")
- } else if issue.IsPull {
- if issue.PullRequest.IsWorkInProgress(ctx) {
- state = ctx.Locale.TrString("repo.issues.draft_title")
- } else {
- state = ctx.Locale.TrString("repo.issues.open_title")
- }
- } else {
- state = ctx.Locale.TrString("repo.issues.open_title")
- }
- state = strings.ToLower(state)
-
- issueStats.SetMargin(10)
- if issue.IsPull {
- reviews := map[int64]bool{}
- for _, comment := range issue.Comments {
- if comment.Review != nil {
- reviews[comment.Review.ID] = true
- }
- }
- _, err = issueStats.DrawText(
- fmt.Sprintf("%s, %s, %s",
- ctx.Locale.TrN(
- issue.NumComments,
- "repo.issues.num_comments_1",
- "repo.issues.num_comments",
- issue.NumComments,
- ),
- ctx.Locale.TrN(
- len(reviews),
- "repo.issues.num_reviews_one",
- "repo.issues.num_reviews_few",
- len(reviews),
- ),
- state,
- ),
- color.Gray{128}, 36, card.Top, card.Left)
- } else {
- _, err = issueStats.DrawText(
- fmt.Sprintf("%s, %s",
- ctx.Locale.TrN(
- issue.NumComments,
- "repo.issues.num_comments_1",
- "repo.issues.num_comments",
- issue.NumComments,
- ),
- state,
- ),
- color.Gray{128}, 36, card.Top, card.Left)
- }
- if err != nil {
- return nil, err
- }
-
- issueAttributionIcon, issueAttributionText := issueAttribution.Split(true, 8)
- issueAttributionText.SetMargin(5)
- _, err = issueAttributionText.DrawText(
- fmt.Sprintf(
- "%s - %s",
- issue.Poster.Name,
- issue.Created.AsTime().Format("2006-01-02"),
- ),
- color.Gray{128}, 36, card.Middle, card.Left)
- if err != nil {
- return nil, err
- }
- err = drawUser(ctx, issueAttributionIcon, issue.Poster)
- if err != nil {
- return nil, err
- }
-
- return mainCard, nil
-}
-
-func drawUser(ctx *context.Context, card *card.Card, user *user_model.User) error {
- if user.UseCustomAvatar {
- posterAvatarPath := user.CustomAvatarRelativePath()
- if posterAvatarPath != "" {
- userAvatarFile, err := storage.Avatars.Open(user.CustomAvatarRelativePath())
- if err != nil {
- return err
- }
- userAvatarImage, _, err := image.Decode(userAvatarFile)
- if err != nil {
- return err
- }
- card.DrawImage(userAvatarImage)
- }
- } else {
- posterAvatarLink := user.AvatarLinkWithSize(ctx, 256)
- card.DrawExternalImage(posterAvatarLink)
- }
- return nil
-}
-
// UpdateIssueTitle change issue's title
func UpdateIssueTitle(ctx *context.Context) {
issue := GetActionIssue(ctx)
diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go
index 65d526d2f2..1791788743 100644
--- a/routers/web/repo/release.go
+++ b/routers/web/repo/release.go
@@ -365,11 +365,7 @@ func SingleRelease(ctx *context.Context) {
addVerifyTagToContext(ctx)
ctx.Data["PageIsSingleTag"] = release.IsTag
- if release.IsTag {
- ctx.Data["Title"] = release.TagName
- } else {
- ctx.Data["Title"] = release.Title
- }
+ ctx.Data["Title"] = release.DisplayName()
err = release.LoadArchiveDownloadCount(ctx)
if err != nil {
@@ -378,6 +374,13 @@ func SingleRelease(ctx *context.Context) {
}
ctx.Data["Releases"] = releases
+
+ ctx.Data["OpenGraphTitle"] = fmt.Sprintf("%s - %s", release.DisplayName(), release.Repo.FullName())
+ ctx.Data["OpenGraphDescription"] = base.EllipsisString(release.Note, 300)
+ ctx.Data["OpenGraphURL"] = release.HTMLURL()
+ ctx.Data["OpenGraphImageURL"] = release.SummaryCardURL()
+ ctx.Data["OpenGraphImageAltText"] = ctx.Tr("repo.release.summary_card_alt", release.DisplayName(), release.Repo.FullName())
+
ctx.HTML(http.StatusOK, tplReleasesList)
}
diff --git a/routers/web/web.go b/routers/web/web.go
index 6061863895..4d8d280c89 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1146,9 +1146,10 @@ 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)
+ m.Get("/summary-card", repo.DrawIssueSummaryCard)
})
})
+ m.Get("/-/summary-card", repo.DrawRepoSummaryCard)
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) // for "/{username}/{reponame}" which doesn't require authentication
// Grouping for those endpoints that do require authentication
@@ -1298,6 +1299,7 @@ func registerRoutes(m *web.Route) {
m.Get("/latest", repo.LatestRelease)
m.Get(".rss", feedEnabled, repo.ReleasesFeedRSS)
m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom)
+ m.Get("/summary-card/*", repo.DrawReleaseSummaryCard)
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed),
repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag, true))
m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment)
diff --git a/services/context/repo.go b/services/context/repo.go
index 45a046eff6..462d843bfc 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -25,6 +25,7 @@ import (
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/card"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
code_indexer "code.gitea.io/gitea/modules/indexer/code"
@@ -632,6 +633,12 @@ func RepoAssignment(ctx *Context) context.CancelFunc {
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
}
+ cardWidth, cardHeight := card.DefaultSize()
+ ctx.Data["OpenGraphImageURL"] = repo.SummaryCardURL()
+ ctx.Data["OpenGraphImageWidth"] = cardWidth
+ ctx.Data["OpenGraphImageHeight"] = cardHeight
+ ctx.Data["OpenGraphImageAltText"] = ctx.Tr("repo.summary_card_alt", repo.FullName())
+
if repo.IsFork {
RetrieveBaseRepo(ctx, repo)
if ctx.Written() {
diff --git a/templates/base/head_opengraph.tmpl b/templates/base/head_opengraph.tmpl
index be9829bf97..692f1797b6 100644
--- a/templates/base/head_opengraph.tmpl
+++ b/templates/base/head_opengraph.tmpl
@@ -1,4 +1,25 @@
{{- /* og:description - a one to two sentence description of your object, maybe it only needs at most 300 bytes */ -}}
+{{if .OpenGraphTitle}}
+
+{{end}}
+{{if .OpenGraphDescription}}
+
+{{end}}
+{{if .OpenGraphURL}}
+
+{{end}}
+{{if .OpenGraphImageURL}}
+
+ {{if .OpenGraphImageWidth}}
+
+ {{end}}
+ {{if .OpenGraphImageHeight}}
+
+ {{end}}
+ {{if .OpenGraphImageAltText}}
+
+ {{end}}
+{{end}}
{{if .PageIsUserProfile}}
@@ -14,10 +35,6 @@
{{if .Issue.Content}}
{{end}}
-
-
-
-
{{else if or .PageIsDiff .IsViewFile}}
@@ -35,14 +52,18 @@
{{end}}
{{else}}
-
-
- {{if .Repository.Description}}
+ {{if not .OpenGraphTitle}}
+
+ {{end}}
+ {{if not .OpenGraphURL}}
+
+ {{end}}
+ {{if and (.Repository.Description) (not .OpenGraphDescription)}}
{{end}}
{{end}}
- {{if not .Issue}}
+ {{if and (not .Issue) (not .OpenGraphImageURL)}}
{{if (.Repository.AvatarLink ctx)}}
{{else}}
diff --git a/tests/integration/opengraph_test.go b/tests/integration/opengraph_test.go
index 40013bd247..d54f59c9b8 100644
--- a/tests/integration/opengraph_test.go
+++ b/tests/integration/opengraph_test.go
@@ -94,45 +94,71 @@ func TestOpenGraphProperties(t *testing.T) {
name: "file in repo",
url: "/user27/repo49/src/branch/master/test/test.txt",
expected: map[string]string{
- "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": setting.AppURL + "assets/img/avatar_default.png",
- "og:site_name": siteName,
+ "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": setting.AppURL + "user27/repo49/-/summary-card",
+ "og:image:alt": "Summary card of repository user27/repo49",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
{
name: "wiki page for repo without description",
url: "/user2/repo1/wiki/Page-With-Spaced-Name",
expected: map[string]string{
- "og:title": "Page With Spaced Name",
- "og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
- "og:type": "object",
- "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
- "og:site_name": siteName,
+ "og:title": "Page With Spaced Name",
+ "og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user2/repo1/-/summary-card",
+ "og:image:alt": "Summary card of repository user2/repo1",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
{
name: "index page for repo without description",
url: "/user2/repo1",
expected: map[string]string{
- "og:title": "repo1",
- "og:url": setting.AppURL + "user2/repo1",
- "og:type": "object",
- "og:image": setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
- "og:site_name": siteName,
+ "og:title": "repo1",
+ "og:url": setting.AppURL + "user2/repo1",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user2/repo1/-/summary-card",
+ "og:image:alt": "Summary card of repository user2/repo1",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
{
name: "index page for repo with description",
url: "/user27/repo49",
expected: map[string]string{
- "og:title": "repo49",
- "og:url": setting.AppURL + "user27/repo49",
- "og:description": "A wonderful repository with more than just a README.md",
- "og:type": "object",
- "og:image": setting.AppURL + "assets/img/avatar_default.png",
- "og:site_name": siteName,
+ "og:title": "repo49",
+ "og:url": setting.AppURL + "user27/repo49",
+ "og:description": "A wonderful repository with more than just a README.md",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user27/repo49/-/summary-card",
+ "og:image:alt": "Summary card of repository user27/repo49",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "release",
+ url: "/user2/repo1/releases/tag/v1.1",
+ expected: map[string]string{
+ "og:title": "testing-release - user2/repo1",
+ "og:url": setting.AppURL + "user2/repo1/releases/tag/v1.1",
+ "og:type": "object",
+ "og:image": setting.AppURL + "user2/repo1/releases/summary-card/v1.1",
+ "og:image:alt": "Summary card of an release titled \"testing-release\" in repository user2/repo1",
+ "og:image:width": "1200",
+ "og:image:height": "600",
+ "og:site_name": siteName,
},
},
}
@@ -166,6 +192,10 @@ func TestOpenGraphSummaryCard(t *testing.T) {
name string
url string
}{
+ {
+ name: "repo",
+ url: "/user2/repo1/-/summary-card",
+ },
{
name: "issue",
url: "/user2/repo1/issues/1/summary-card",
@@ -174,6 +204,10 @@ func TestOpenGraphSummaryCard(t *testing.T) {
name: "pull request",
url: "/user2/repo1/pulls/2/summary-card",
},
+ {
+ name: "release",
+ url: "/user2/repo1/releases/summary-card/v1.1",
+ },
}
for _, tc := range cases {