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 {