diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go
index 28452e4cf1..e8fc1ae743 100644
--- a/routers/web/pages/pages.go
+++ b/routers/web/pages/pages.go
@@ -5,10 +5,14 @@ package pages
import (
"crypto/rand"
+ "crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html/template"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
"io"
"math/big"
"net/http"
@@ -28,6 +32,8 @@ import (
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/markup/markdown"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
+ "code.gitcaddy.com/server/v3/modules/socialcard"
+ "code.gitcaddy.com/server/v3/modules/storage"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/services/context"
pages_service "code.gitcaddy.com/server/v3/services/pages"
@@ -70,6 +76,12 @@ func ServeLandingPage(ctx *context.Context) {
return
}
+ // Handle social preview image
+ if requestPath == "/social-preview.png" || requestPath == "/social-preview" {
+ serveSocialPreview(ctx, repo)
+ return
+ }
+
// Handle asset requests (gallery images, custom assets)
// Uses /pages/assets/ to avoid conflict with Gitea's static /assets/ route
if assetPath, found := strings.CutPrefix(requestPath, "/pages/assets/"); found && assetPath != "" {
@@ -164,7 +176,13 @@ func setupLandingPageContext(ctx *context.Context, repo *repo_model.Repository,
if ctx.Req.TLS == nil && strings.HasPrefix(ctx.Req.Host, "localhost") {
scheme = "http"
}
- ctx.Data["PageURL"] = scheme + "://" + ctx.Req.Host + ctx.Req.URL.RequestURI()
+ pageBase := scheme + "://" + ctx.Req.Host
+ ctx.Data["PageURL"] = pageBase + ctx.Req.URL.RequestURI()
+ ctx.Data["SocialPreviewURL"] = pageBase + "/" + repo.OwnerName + "/" + repo.Name + "/social-preview.png"
+ // On custom domains, use a relative path so the URL resolves to the custom domain
+ if ctx.Repo == nil || ctx.Repo.Repository == nil {
+ ctx.Data["SocialPreviewURL"] = pageBase + "/social-preview.png"
+ }
ctx.Data["NumStars"] = repo.NumStars
ctx.Data["NumForks"] = repo.NumForks
ctx.Data["Year"] = time.Now().Year()
@@ -551,6 +569,82 @@ func serveCustomDomainAsset(ctx *context.Context, repo *repo_model.Repository, a
serveRepoFileAsset(ctx, commit, assetPath)
}
+// serveSocialPreview generates and serves the social card image for a repo
+// on custom domain / subdomain requests.
+func serveSocialPreview(ctx *context.Context, repo *repo_model.Repository) {
+ if err := repo.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return
+ }
+
+ title := repo.DisplayTitle
+ if title == "" {
+ title = repo.Name
+ }
+
+ ownerName := repo.OwnerName
+ if repo.Owner != nil {
+ ownerName = repo.Owner.DisplayName()
+ }
+ repoName := repo.Name
+ if repo.DisplayTitle != "" {
+ repoName = repo.DisplayTitle
+ }
+
+ data := socialcard.CardData{
+ Title: title,
+ Description: repo.Description,
+ RepoAvatarURL: repo.AvatarLink(ctx),
+ RepoFullName: ownerName + " / " + repoName,
+ BrandName: repo.OwnerDisplayName,
+ SolidColor: repo.SocialCardColor,
+ UnsplashAuthor: repo.SocialCardUnsplashAuthor,
+ }
+
+ if repo.PrimaryLanguage != nil {
+ data.LanguageName = repo.PrimaryLanguage.Language
+ data.LanguageColor = repo.PrimaryLanguage.Color
+ }
+
+ themeName := repo.SocialCardTheme
+ if (themeName == "image" || themeName == "solid") && repo.SocialCardBgImage != "" {
+ if fr, err := storage.RepoAvatars.Open(repo.SocialCardBgImage); err == nil {
+ defer fr.(io.ReadCloser).Close()
+ if img, _, err := image.Decode(fr.(io.Reader)); err == nil {
+ data.BgImage = img
+ }
+ }
+ }
+
+ theme := socialcard.GetTheme(themeName)
+ renderer, err := socialcard.GlobalRenderer()
+ if err != nil {
+ ctx.ServerError("socialcard.GlobalRenderer", err)
+ return
+ }
+
+ pngData, err := renderer.RenderCard(data, theme)
+ if err != nil {
+ ctx.ServerError("RenderCard", err)
+ return
+ }
+
+ hash := sha256.Sum256(pngData)
+ etag := fmt.Sprintf(`"%x"`, hash[:16])
+
+ ctx.Resp.Header().Set("Content-Type", "image/png")
+ ctx.Resp.Header().Set("Cache-Control", "public, max-age=1800")
+ ctx.Resp.Header().Set("ETag", etag)
+
+ if ctx.Req.Header.Get("If-None-Match") == etag {
+ ctx.Resp.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ ctx.Resp.WriteHeader(http.StatusOK)
+ _, _ = ctx.Resp.Write(pngData)
+}
+
// ServePageAsset serves static assets for the landing page
func ServePageAsset(ctx *context.Context) {
repo, _, err := getRepoFromRequest(ctx)
diff --git a/routers/web/web.go b/routers/web/web.go
index da200ad492..60a4ad5c10 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1398,6 +1398,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Get("/{username}/{reponame}/social-preview", optSignIn, context.RepoAssignment, repo.SocialPreview)
+ m.Get("/{username}/{reponame}/social-preview.png", optSignIn, context.RepoAssignment, repo.SocialPreview)
// Subscribe page (requires sign-in, no code access check)
m.Get("/{username}/{reponame}/subscribe", reqSignIn, context.RepoAssignment, repo.Subscribe)
diff --git a/templates/base/head_opengraph.tmpl b/templates/base/head_opengraph.tmpl
index 8afc7203b0..adb17276e1 100644
--- a/templates/base/head_opengraph.tmpl
+++ b/templates/base/head_opengraph.tmpl
@@ -61,7 +61,7 @@
{{end}}
{{end}}
-
+
{{if or (eq .Repository.SocialCardTheme "solid") (eq .Repository.SocialCardTheme "image")}}
@@ -71,7 +71,7 @@
{{end}}
-
+
{{else}}
diff --git a/templates/pages/base_head.tmpl b/templates/pages/base_head.tmpl
index 1db6b573f9..71b7ca2f4c 100644
--- a/templates/pages/base_head.tmpl
+++ b/templates/pages/base_head.tmpl
@@ -22,8 +22,8 @@
{{else if .Config.SEO.UseMediaKitOG}}
-
-
+
+
{{else if .Config.SEO.OGImage}}
@@ -40,8 +40,8 @@
{{if .Config.SEO.UseMediaKitOG}}
-
-
+
+
{{else if .Config.SEO.OGImage}}