From 7685a1e98e96a2602d7b50cd767d18e0d1d86128 Mon Sep 17 00:00:00 2001
From: JakobDev <jakobdev@gmx.de>
Date: Sat, 14 Dec 2024 19:36:09 +0100
Subject: [PATCH] feat: Add summary card for repos and releases

---
 models/repo/release.go              |  55 ++-
 models/repo/release_test.go         |  24 ++
 models/repo/repo.go                 |   5 +
 modules/card/card.go                |  34 +-
 routers/web/repo/card.go            | 522 ++++++++++++++++++++++++++++
 routers/web/repo/issue.go           | 222 ------------
 routers/web/repo/release.go         |  12 +-
 routers/web/web.go                  |   4 +-
 services/context/repo.go            |   2 +
 templates/base/head_opengraph.tmpl  |  24 +-
 tests/integration/opengraph_test.go |  16 +-
 11 files changed, 673 insertions(+), 247 deletions(-)
 create mode 100644 routers/web/repo/card.go

diff --git a/models/repo/release.go b/models/repo/release.go
index 38e38c6572..86cb553da0 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 dowlaod 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/%d", r.Repo.HTMLURL(), r.ID)
+}
+
+// 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..c8e489b9a7 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.Equal(t, int64(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..dc3fa7e860 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 sie 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,12 @@ 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) {
+	for x := startX; x <= endX; x++ {
+		for y := startY; y <= endY; y++ {
+			c.Img.Set(x, y, color)
+		}
+	}
+}
diff --git a/routers/web/repo/card.go b/routers/web/repo/card.go
new file mode 100644
index 0000000000..3564d6486c
--- /dev/null
+++ b/routers/web/repo/card.go
@@ -0,0 +1,522 @@
+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 avator 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
+	} else {
+		// 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 := 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)
+	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 cahce 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 Crad 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.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":releaseID"))
+	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 78fb5e6c01..b86749ec69 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"
@@ -2218,222 +2212,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..ae7a645791 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,12 @@ 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.HTML(http.StatusOK, tplReleasesList)
 }
 
diff --git a/routers/web/web.go b/routers/web/web.go
index 6061863895..c624ea6e59 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/{releaseID}", 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..de4e33eb1f 100644
--- a/services/context/repo.go
+++ b/services/context/repo.go
@@ -632,6 +632,8 @@ func RepoAssignment(ctx *Context) context.CancelFunc {
 		ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
 	}
 
+	ctx.Data["OpenGraphImageURL"] = repo.SummaryCardURL()
+
 	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..bcdfa25695 100644
--- a/templates/base/head_opengraph.tmpl
+++ b/templates/base/head_opengraph.tmpl
@@ -1,4 +1,16 @@
 {{- /* og:description - a one to two sentence description of your object, maybe it only needs at most 300 bytes */ -}}
+{{if .OpenGraphTitle}}
+	<meta property="og:title" content="{{.OpenGraphTitle}}">
+{{end}}
+{{if .OpenGraphDescription}}
+	<meta property="og:description" content="{{.OpenGraphDescription}}">
+{{end}}
+{{if .OpenGraphURL}}
+	<meta property="og:url" content="{{.OpenGraphURL}}">
+{{end}}
+{{if .OpenGraphImageURL}}
+	<meta property="og:image" content="{{.OpenGraphImageURL}}">
+{{end}}
 {{if .PageIsUserProfile}}
 	<meta property="og:title" content="{{.ContextUser.DisplayName}}">
 	<meta property="og:type" content="profile">
@@ -35,14 +47,18 @@
 			<meta property="og:description" content="{{StringUtils.EllipsisString .Repository.Description 300}}">
 		{{end}}
 	{{else}}
-		<meta property="og:title" content="{{.Repository.Name}}">
-		<meta property="og:url" content="{{.Repository.HTMLURL}}">
-		{{if .Repository.Description}}
+		{{if not .OpenGraphTitle}}
+			<meta property="og:title" content="{{.Repository.Name}}">
+		{{end}}
+		{{if not .OpenGraphURL}}
+			<meta property="og:url" content="{{.Repository.HTMLURL}}">
+		{{end}}
+		{{if and (.Repository.Description) (not .OpenGraphDescription)}}
 			<meta property="og:description" content="{{StringUtils.EllipsisString .Repository.Description 300}}">
 		{{end}}
 	{{end}}
 	<meta property="og:type" content="object">
-	{{if not .Issue}}
+	{{if and (not .Issue) (not .OpenGraphImageURL)}}
 		{{if (.Repository.AvatarLink ctx)}}
 			<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
 		{{else}}
diff --git a/tests/integration/opengraph_test.go b/tests/integration/opengraph_test.go
index 40013bd247..89e2a85acd 100644
--- a/tests/integration/opengraph_test.go
+++ b/tests/integration/opengraph_test.go
@@ -97,7 +97,7 @@ func TestOpenGraphProperties(t *testing.T) {
 				"og:title":     "repo49/test/test.txt at master",
 				"og:url":       setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt",
 				"og:type":      "object",
-				"og:image":     setting.AppURL + "assets/img/avatar_default.png",
+				"og:image":     setting.AppURL + "user27/repo49/-/summary-card",
 				"og:site_name": siteName,
 			},
 		},
@@ -108,7 +108,7 @@ func TestOpenGraphProperties(t *testing.T) {
 				"og:title":     "Page With Spaced Name",
 				"og:url":       setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
 				"og:type":      "object",
-				"og:image":     setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
+				"og:image":     setting.AppURL + "user2/repo1/-/summary-card",
 				"og:site_name": siteName,
 			},
 		},
@@ -119,7 +119,7 @@ func TestOpenGraphProperties(t *testing.T) {
 				"og:title":     "repo1",
 				"og:url":       setting.AppURL + "user2/repo1",
 				"og:type":      "object",
-				"og:image":     setting.AppURL + "avatars/ab53a2911ddf9b4817ac01ddcd3d975f",
+				"og:image":     setting.AppURL + "user2/repo1/-/summary-card",
 				"og:site_name": siteName,
 			},
 		},
@@ -131,7 +131,7 @@ func TestOpenGraphProperties(t *testing.T) {
 				"og:url":         setting.AppURL + "user27/repo49",
 				"og:description": "A wonderful repository with more than just a README.md",
 				"og:type":        "object",
-				"og:image":       setting.AppURL + "assets/img/avatar_default.png",
+				"og:image":       setting.AppURL + "user27/repo49/-/summary-card",
 				"og:site_name":   siteName,
 			},
 		},
@@ -166,6 +166,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 +178,10 @@ func TestOpenGraphSummaryCard(t *testing.T) {
 			name: "pull request",
 			url:  "/user2/repo1/pulls/2/summary-card",
 		},
+		{
+			name: "release",
+			url:  "/user2/repo1/releases/summary-card/1",
+		},
 	}
 
 	for _, tc := range cases {