2
0

feat(pages): serve social preview on custom domains
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m1s
Build and Release / Unit Tests (push) Successful in 4m35s
Build and Release / Lint (push) Failing after 5m58s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped

Add /social-preview.png endpoint for custom domain/subdomain landing pages. Generates social card using same logic as repo social preview but accessible on custom domains. Adds .png extension to social preview URLs for better compatibility with social media crawlers. Updates og:image and twitter:image meta tags to use domain-relative URLs on custom domains. Includes ETag caching with 30min max-age.
This commit is contained in:
2026-03-16 02:46:41 -04:00
parent c24d329a6a
commit e8b6303971
4 changed files with 102 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -61,7 +61,7 @@
{{end}}
{{end}}
<meta property="og:type" content="object">
<meta property="og:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta property="og:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview.png">
{{if or (eq .Repository.SocialCardTheme "solid") (eq .Repository.SocialCardTheme "image")}}
<meta property="og:image:width" content="1080">
<meta property="og:image:height" content="1350">
@@ -71,7 +71,7 @@
{{end}}
<meta property="og:image:type" content="image/png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta name="twitter:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview.png">
{{else}}
<meta property="og:title" content="{{AppName}}">
<meta property="og:type" content="website">

View File

@@ -22,8 +22,8 @@
<meta name="twitter:image" content="{{.BlogPost.FeaturedImage.DownloadURL}}">
<meta name="twitter:card" content="summary_large_image">
{{else if .Config.SEO.UseMediaKitOG}}
<meta property="og:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta name="twitter:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta property="og:image" content="{{.SocialPreviewURL}}">
<meta name="twitter:image" content="{{.SocialPreviewURL}}">
<meta name="twitter:card" content="summary_large_image">
{{else if .Config.SEO.OGImage}}
<meta property="og:image" content="{{.Config.SEO.OGImage}}">
@@ -40,8 +40,8 @@
<meta name="twitter:title" content="{{if .Config.SEO.Title}}{{.Config.SEO.Title}}{{else if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}}">
<meta name="twitter:description" content="{{if .Config.SEO.Description}}{{.Config.SEO.Description}}{{else if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}">
{{if .Config.SEO.UseMediaKitOG}}
<meta property="og:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta name="twitter:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta property="og:image" content="{{.SocialPreviewURL}}">
<meta name="twitter:image" content="{{.SocialPreviewURL}}">
<meta name="twitter:card" content="summary_large_image">
{{else if .Config.SEO.OGImage}}
<meta property="og:image" content="{{.Config.SEO.OGImage}}">