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}}