2
0
Files
gitcaddy-server/routers/web/pages/pages.go
logikonline d45354b538 fix(pages): fix navigation anchor links from blog pages
Add LandingURL context variable that resolves to landing page base path. Use it for all navigation anchor links (#features, #pricing, etc.) so they navigate correctly from blog detail/list pages back to landing page sections. For repo-path mode uses /{owner}/{repo}/pages, for custom domains uses /. Fixes broken navigation when viewing blog posts.
2026-03-16 22:37:48 -04:00

1262 lines
35 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html/template"
"image"
_ "image/jpeg" // register JPEG decoder for social card background images
_ "image/png" // register PNG decoder for social card background images
"io"
"math/big"
"net/http"
"path"
"slices"
"strconv"
"strings"
"time"
blog_model "code.gitcaddy.com/server/v3/models/blog"
pages_model "code.gitcaddy.com/server/v3/models/pages"
"code.gitcaddy.com/server/v3/models/renderhelper"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/git"
"code.gitcaddy.com/server/v3/modules/gitrepo"
"code.gitcaddy.com/server/v3/modules/json"
"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"
)
const (
tplPagesOpenSourceHero templates.TplName = "pages/open-source-hero"
tplPagesMinimalistDocs templates.TplName = "pages/minimalist-docs"
tplPagesSaasConversion templates.TplName = "pages/saas-conversion"
tplPagesBoldMarketing templates.TplName = "pages/bold-marketing"
tplPagesDocumentationFirst templates.TplName = "pages/documentation-first"
tplPagesDeveloperTool templates.TplName = "pages/developer-tool"
tplPagesVisualShowcase templates.TplName = "pages/visual-showcase"
tplPagesCLITerminal templates.TplName = "pages/cli-terminal"
tplPagesArchitectureDeepDive templates.TplName = "pages/architecture-deep-dive"
)
// ServeLandingPage serves the landing page for a repository
func ServeLandingPage(ctx *context.Context) {
// Get the repository from subdomain or custom domain
repo, config, err := getRepoFromRequest(ctx)
if err != nil {
log.Error("Failed to get repo from pages request: %v", err)
ctx.NotFound(err)
return
}
if repo == nil || config == nil || !config.Enabled {
ctx.NotFound(errors.New("pages not configured"))
return
}
// Check for redirect
requestPath := ctx.Req.URL.Path
if config.Advanced.Redirects != nil {
if redirect, ok := config.Advanced.Redirects[requestPath]; ok {
ctx.Redirect(redirect)
return
}
}
// Handle event tracking POST
if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") {
servePageEvent(ctx, repo)
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 != "" {
serveCustomDomainAsset(ctx, repo, assetPath)
return
}
// Check for blog paths on custom domain
if config.Blog.Enabled && repo.BlogEnabled {
if idStr, found := strings.CutPrefix(requestPath, "/blog/"); found {
idStr = strings.TrimRight(idStr, "/")
blogID, err := strconv.ParseInt(idStr, 10, 64)
if err == nil && blogID > 0 {
config = applyLanguageOverlay(ctx, repo, config)
serveBlogDetail(ctx, repo, config, blogID, "/blog")
return
}
} else if requestPath == "/blog" || requestPath == "/blog/" {
config = applyLanguageOverlay(ctx, repo, config)
serveBlogList(ctx, repo, config, "/blog")
return
}
}
// Render the landing page with A/B test variant and language overlay
ctx.Data["BlogBaseURL"] = "/blog"
config = assignVariant(ctx, repo, config)
config = applyLanguageOverlay(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
// getRepoFromRequest extracts the repository from the pages request
func getRepoFromRequest(ctx *context.Context) (*repo_model.Repository, *pages_module.LandingConfig, error) {
host := ctx.Req.Host
// Check for custom domain first
repo, err := pages_service.GetRepoByPagesDomain(ctx, host)
if err == nil && repo != nil {
config, err := pages_service.GetPagesConfig(ctx, repo)
if err != nil {
return nil, nil, err
}
return repo, config, nil
}
// Parse subdomain: {repo}-{owner}.{domain}
parts := strings.Split(host, ".")
if len(parts) < 2 {
return nil, nil, errors.New("invalid pages subdomain")
}
// First part is {repo}-{owner}
repoOwner := strings.SplitN(parts[0], "-", 2)
if len(repoOwner) != 2 {
return nil, nil, errors.New("invalid pages subdomain format")
}
repoName := repoOwner[0]
ownerName := repoOwner[1]
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err != nil {
return nil, nil, err
}
config, err := pages_service.GetPagesConfig(ctx, repo)
if err != nil {
return nil, nil, err
}
return repo, config, nil
}
// setupLandingPageContext sets up the shared template context data used by both
// the landing page and blog views (nav, footer, logo, repo info, etc.)
func setupLandingPageContext(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) {
// Ensure Owner is loaded — templates access .Repository.Owner.Name
if err := repo.LoadOwner(ctx); err != nil {
log.Error("Failed to load repo owner for pages: %v", err)
}
ctx.Data["Repository"] = repo
ctx.Data["Config"] = config
if ctx.Data["Title"] == nil || ctx.Data["Title"] == "" {
ctx.Data["Title"] = getPageTitle(repo, config)
}
ctx.Data["PageIsPagesLanding"] = true
ctx.Data["RepoURL"] = repo.HTMLURL()
ctx.Data["LogoURL"] = resolveLogoURL(ctx, repo, config)
// Build canonical page URL for og:url
scheme := "https"
if ctx.Req.TLS == nil && strings.HasPrefix(ctx.Req.Host, "localhost") {
scheme = "http"
}
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"
}
// LandingURL is the base path for the landing page — used by nav anchor links
// so they resolve correctly from blog detail/list pages.
if ctx.Repo != nil && ctx.Repo.Repository != nil {
ctx.Data["LandingURL"] = fmt.Sprintf("/%s/%s/pages", repo.OwnerName, repo.Name)
} else {
ctx.Data["LandingURL"] = "/"
}
ctx.Data["NumStars"] = repo.NumStars
ctx.Data["NumForks"] = repo.NumForks
ctx.Data["Year"] = time.Now().Year()
// Load latest release with attachments
release, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID)
if err == nil && release != nil {
_ = repo_model.GetReleaseAttachments(ctx, release)
ctx.Data["LatestRelease"] = release
ctx.Data["LatestReleaseTag"] = strings.TrimPrefix(release.TagName, "v")
}
ctx.Data["PublicReleases"] = config.Advanced.PublicReleases
ctx.Data["HideMobileReleases"] = config.Advanced.HideMobileReleases
ctx.Data["GooglePlayID"] = config.Advanced.GooglePlayID
ctx.Data["AppStoreID"] = config.Advanced.AppStoreID
}
// galleryAssetBaseURL returns the base URL for gallery image assets.
// For repo-path mode (ctx.Repo is set), it returns /{owner}/{repo}/pages/assets/gallery/
// For custom domain / subdomain mode, it returns /pages/assets/gallery/
func galleryAssetBaseURL(ctx *context.Context, repo *repo_model.Repository) string {
if ctx.Repo != nil && ctx.Repo.Repository != nil {
return repo.Link() + "/pages/assets/gallery/"
}
return "/pages/assets/gallery/"
}
// renderLandingPage renders the landing page based on the template
func renderLandingPage(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) {
setupLandingPageContext(ctx, repo, config)
// Load README content
readme, err := loadReadmeContent(ctx, repo)
if err != nil {
log.Warn("Failed to load README: %v", err)
}
ctx.Data["ReadmeContent"] = readme
// Load recent blog posts if blog section is enabled
if config.Blog.Enabled && repo.BlogEnabled {
maxPosts := config.Blog.MaxPosts
if maxPosts <= 0 {
maxPosts = 3
}
posts, _, err := blog_model.GetBlogPostsByRepoID(ctx, &blog_model.BlogPostSearchOptions{
RepoID: repo.ID,
AnyPublicStatus: true,
Page: 1,
PageSize: maxPosts,
})
if err == nil && len(posts) > 0 {
for _, post := range posts {
_ = post.LoadAuthor(ctx)
_ = post.LoadFeaturedImage(ctx)
}
ctx.Data["BlogPosts"] = posts
}
}
// Load gallery images if gallery section is enabled
if config.Gallery.Enabled {
baseURL := galleryAssetBaseURL(ctx, repo)
images := loadGalleryImagesForLanding(ctx, repo, config, baseURL)
if len(images) > 0 {
ctx.Data["GalleryImages"] = images
}
}
tpl := selectTemplate(config.Template)
ctx.HTML(http.StatusOK, tpl)
}
// serveBlogDetail renders a single blog post within the landing page theme
func serveBlogDetail(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig, blogID int64, blogBaseURL string) {
post, err := blog_model.GetBlogPostByID(ctx, blogID)
if err != nil {
ctx.NotFound(err)
return
}
// Verify post belongs to this repo and is public
if post.RepoID != repo.ID {
ctx.NotFound(errors.New("post not found"))
return
}
if post.Status < blog_model.BlogPostPublic {
ctx.NotFound(errors.New("post not available"))
return
}
_ = post.LoadAuthor(ctx)
_ = post.LoadFeaturedImage(ctx)
// Render markdown content
rctx := renderhelper.NewRenderContextRepoComment(ctx, repo)
rendered, err := markdown.RenderString(rctx, post.Content)
if err != nil {
log.Error("Failed to render blog post markdown: %v", err)
ctx.ServerError("Failed to render blog post", err)
return
}
ctx.Data["Title"] = post.Title + " - " + getPageTitle(repo, config)
ctx.Data["BlogBaseURL"] = blogBaseURL
setupLandingPageContext(ctx, repo, config)
ctx.Data["PageIsBlogDetail"] = true
ctx.Data["BlogPost"] = post
ctx.Data["BlogRenderedContent"] = rendered
if post.Tags != "" {
ctx.Data["BlogTags"] = strings.Split(post.Tags, ",")
}
tpl := selectTemplate(config.Template)
ctx.HTML(http.StatusOK, tpl)
}
// serveBlogList renders a paginated list of blog posts within the landing page theme
func serveBlogList(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig, blogBaseURL string) {
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
pageSize := 9
posts, total, err := blog_model.GetBlogPostsByRepoID(ctx, &blog_model.BlogPostSearchOptions{
RepoID: repo.ID,
AnyPublicStatus: true,
Page: page,
PageSize: pageSize,
})
if err != nil {
ctx.NotFound(err)
return
}
for _, post := range posts {
_ = post.LoadAuthor(ctx)
_ = post.LoadFeaturedImage(ctx)
}
ctx.Data["Title"] = "Blog - " + getPageTitle(repo, config)
ctx.Data["BlogBaseURL"] = blogBaseURL
setupLandingPageContext(ctx, repo, config)
ctx.Data["PageIsBlogList"] = true
ctx.Data["BlogListPosts"] = posts
ctx.Data["BlogListTotal"] = total
pager := context.NewPagination(int(total), pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
tpl := selectTemplate(config.Template)
ctx.HTML(http.StatusOK, tpl)
}
// resolveLogoURL determines the logo URL based on the brand.logo_source config
func resolveLogoURL(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) string {
switch config.Brand.LogoSource {
case "repo":
return repo.AvatarLink(ctx)
case "org":
if err := repo.LoadOwner(ctx); err != nil {
log.Warn("Failed to load repo owner for logo: %v", err)
return ""
}
return repo.Owner.AvatarLink(ctx)
default: // "url" or empty
return config.Brand.ResolvedLogoURL()
}
}
// getPageTitle returns the page title
func getPageTitle(repo *repo_model.Repository, config *pages_module.LandingConfig) string {
if config.SEO.Title != "" {
return config.SEO.Title
}
if config.Hero.Headline != "" {
return config.Hero.Headline
}
if config.Brand.Name != "" {
return config.Brand.Name
}
return repo.Name
}
// GalleryImageInfo holds gallery image data for landing page templates
type GalleryImageInfo struct {
Name string
Caption string
URL string
}
// loadGalleryImagesForLanding reads gallery images from the .gallery folder
func loadGalleryImagesForLanding(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig, baseURL string) []GalleryImageInfo {
if repo.IsEmpty {
return nil
}
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil
}
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
if err != nil {
return nil
}
galleryEntry, err := commit.GetTreeEntryByPath(".gallery")
if err != nil || !galleryEntry.IsDir() {
return nil
}
// Load metadata for captions
var metadata struct {
Images []struct {
Name string `json:"name"`
Caption string `json:"caption"`
} `json:"images"`
}
if entry, err := commit.GetTreeEntryByPath(".gallery/gallery.json"); err == nil {
if content, err := entry.Blob().GetBlobContent(100000); err == nil {
_ = json.Unmarshal([]byte(content), &metadata)
}
}
captionMap := make(map[string]string)
for _, img := range metadata.Images {
captionMap[img.Name] = img.Caption
}
tree, err := commit.SubTree(".gallery")
if err != nil {
return nil
}
entries, err := tree.ListEntries()
if err != nil {
return nil
}
maxImages := config.Gallery.MaxImages
if maxImages <= 0 {
maxImages = 6
}
var images []GalleryImageInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if name == "gallery.json" {
continue
}
ext := strings.ToLower(path.Ext(name))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp":
default:
continue
}
images = append(images, GalleryImageInfo{
Name: name,
Caption: captionMap[name],
URL: baseURL + name,
})
if len(images) >= maxImages {
break
}
}
return images
}
// loadReadmeContent loads and renders the README content
func loadReadmeContent(ctx *context.Context, repo *repo_model.Repository) (template.HTML, error) {
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
return "", err
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
return "", err
}
// Find README file
readmePath := findReadmePath(commit)
if readmePath == "" {
return "", errors.New("README not found")
}
entry, err := commit.GetTreeEntryByPath(readmePath)
if err != nil {
return "", err
}
reader, err := entry.Blob().DataAsync()
if err != nil {
return "", err
}
defer reader.Close()
content := make([]byte, entry.Blob().Size())
_, err = reader.Read(content)
if err != nil && err.Error() != "EOF" {
return "", err
}
// Render markdown using renderhelper
rctx := renderhelper.NewRenderContextRepoFile(ctx, repo)
rendered, err := markdown.RenderString(rctx, string(content))
if err != nil {
return "", err
}
return rendered, nil
}
// findReadmePath finds the README file path
func findReadmePath(commit *git.Commit) string {
// Default README locations
readmePaths := []string{
"README.md",
"readme.md",
"Readme.md",
"README.markdown",
"README.txt",
"README",
}
for _, p := range readmePaths {
if _, err := commit.GetTreeEntryByPath(p); err == nil {
return p
}
}
return ""
}
// selectTemplate selects the template based on configuration
func selectTemplate(templateName string) templates.TplName {
switch templateName {
case "minimalist-docs":
return tplPagesMinimalistDocs
case "saas-conversion":
return tplPagesSaasConversion
case "bold-marketing":
return tplPagesBoldMarketing
case "documentation-first":
return tplPagesDocumentationFirst
case "developer-tool":
return tplPagesDeveloperTool
case "visual-showcase":
return tplPagesVisualShowcase
case "cli-terminal":
return tplPagesCLITerminal
case "architecture-deep-dive":
return tplPagesArchitectureDeepDive
case "open-source-hero":
return tplPagesOpenSourceHero
default:
return tplPagesOpenSourceHero
}
}
// serveCustomDomainAsset serves asset files for custom domain / subdomain requests.
func serveCustomDomainAsset(ctx *context.Context, repo *repo_model.Repository, assetPath string) {
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
ctx.NotFound(err)
return
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
ctx.NotFound(err)
return
}
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)
if err != nil {
ctx.NotFound(err)
return
}
// Get the asset path from URL
assetPath := strings.TrimPrefix(ctx.Req.URL.Path, "/assets/")
if assetPath == "" {
ctx.NotFound(errors.New("asset not found"))
return
}
// Load asset from repository
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
ctx.NotFound(err)
return
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
ctx.NotFound(err)
return
}
serveRepoFileAsset(ctx, commit, assetPath)
}
// ServeRepoLandingPage serves the landing page for a repository via URL path
func ServeRepoLandingPage(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.NotFound(errors.New("repository not found"))
return
}
config, err := pages_service.GetPagesConfig(ctx, repo)
if err != nil {
log.Error("Failed to get pages config: %v", err)
ctx.NotFound(err)
return
}
if config == nil || !config.Enabled {
ctx.NotFound(errors.New("pages not enabled for this repository"))
return
}
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = assignVariant(ctx, repo, config)
config = applyLanguageOverlay(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
// ServeRepoBlogList serves the blog listing within the landing page theme via URL path
func ServeRepoBlogList(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.NotFound(errors.New("repository not found"))
return
}
config, err := pages_service.GetPagesConfig(ctx, repo)
if err != nil || config == nil || !config.Enabled {
ctx.NotFound(errors.New("pages not enabled"))
return
}
if !config.Blog.Enabled || !repo.BlogEnabled {
ctx.NotFound(errors.New("blog not enabled"))
return
}
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = applyLanguageOverlay(ctx, repo, config)
serveBlogList(ctx, repo, config, blogBaseURL)
}
// ServeRepoBlogDetail serves a single blog post within the landing page theme via URL path
func ServeRepoBlogDetail(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.NotFound(errors.New("repository not found"))
return
}
config, err := pages_service.GetPagesConfig(ctx, repo)
if err != nil || config == nil || !config.Enabled {
ctx.NotFound(errors.New("pages not enabled"))
return
}
if !config.Blog.Enabled || !repo.BlogEnabled {
ctx.NotFound(errors.New("blog not enabled"))
return
}
blogID := ctx.PathParamInt64("id")
if blogID <= 0 {
ctx.NotFound(errors.New("invalid blog post ID"))
return
}
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = applyLanguageOverlay(ctx, repo, config)
serveBlogDetail(ctx, repo, config, blogID, blogBaseURL)
}
// ServeRepoPageAsset serves static assets for the landing page via URL path
func ServeRepoPageAsset(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.NotFound(errors.New("repository not found"))
return
}
// Get the asset path from URL
assetPath := ctx.PathParam("*")
if assetPath == "" {
ctx.NotFound(errors.New("asset not found"))
return
}
// Load asset from repository
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
ctx.NotFound(err)
return
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
ctx.NotFound(err)
return
}
serveRepoFileAsset(ctx, commit, assetPath)
}
// serveRepoFileAsset resolves an asset path to a file in the repo and streams it.
// For paths starting with "gallery/", it maps to .gallery/ in the repo.
// Otherwise it looks in assets/ then .gitea/assets/.
func serveRepoFileAsset(ctx *context.Context, commit *git.Commit, assetPath string) {
var fullPath string
var entry *git.TreeEntry
var err error
if galleryName, found := strings.CutPrefix(assetPath, "gallery/"); found && galleryName != "" {
// Serve from .gallery/ folder
fullPath = ".gallery/" + galleryName
entry, err = commit.GetTreeEntryByPath(fullPath)
if err != nil {
ctx.NotFound(err)
return
}
} else {
// Try assets folder first
fullPath = path.Join("assets", assetPath)
entry, err = commit.GetTreeEntryByPath(fullPath)
if err != nil {
// Try .gitea/assets
fullPath = path.Join(".gitea", "assets", assetPath)
entry, err = commit.GetTreeEntryByPath(fullPath)
if err != nil {
ctx.NotFound(err)
return
}
}
}
reader, err := entry.Blob().DataAsync()
if err != nil {
ctx.ServerError("Failed to read asset", err)
return
}
defer reader.Close()
// Set content type based on extension
ext := path.Ext(assetPath)
contentType := getContentType(ext)
ctx.Resp.Header().Set("Content-Type", contentType)
ctx.Resp.Header().Set("Cache-Control", "public, max-age=3600")
// Stream content directly to the response
ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(entry.Blob().Size(), 10))
_, _ = io.Copy(ctx.Resp, reader)
}
// servePageEvent handles POST /pages/events for A/B test event tracking
func servePageEvent(ctx *context.Context, repo *repo_model.Repository) {
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, 4096))
if err != nil {
ctx.Status(http.StatusBadRequest)
return
}
var payload struct {
EventType string `json:"event_type"`
VariantID int64 `json:"variant_id"`
ExperimentID int64 `json:"experiment_id"`
VisitorID string `json:"visitor_id"`
Data string `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
ctx.Status(http.StatusBadRequest)
return
}
// Validate event type
validTypes := map[string]bool{
pages_model.EventTypeImpression: true,
pages_model.EventTypeCTAClick: true,
pages_model.EventTypeScrollDepth: true,
pages_model.EventTypeClick: true,
}
if !validTypes[payload.EventType] {
ctx.Status(http.StatusBadRequest)
return
}
// Record event
_ = pages_model.CreatePageEvent(ctx, &pages_model.PageEvent{
RepoID: repo.ID,
VariantID: payload.VariantID,
ExperimentID: payload.ExperimentID,
VisitorID: payload.VisitorID,
EventType: payload.EventType,
EventData: payload.Data,
})
// Update denormalized counters
if payload.VariantID > 0 {
switch payload.EventType {
case pages_model.EventTypeImpression:
_ = pages_model.IncrementVariantImpressions(ctx, payload.VariantID)
case pages_model.EventTypeCTAClick:
_ = pages_model.IncrementVariantConversions(ctx, payload.VariantID)
}
}
ctx.Status(http.StatusNoContent)
}
// ServePageEvent handles POST event tracking via path-based routes
func ServePageEvent(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.Status(http.StatusNotFound)
return
}
servePageEvent(ctx, repo)
}
// assignVariant checks for an active experiment, assigns the visitor to a variant,
// and deep-merges the variant's config overrides onto the base config.
func assignVariant(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
if !config.Experiments.Enabled {
return config
}
exp, err := pages_model.GetActiveExperimentByRepoID(ctx, repo.ID)
if err != nil || exp == nil {
return config
}
// Ensure visitor ID cookie
visitorID := ctx.GetSiteCookie("pgvid")
if visitorID == "" {
visitorID = generateVisitorID()
ctx.SetSiteCookie("pgvid", visitorID, 86400*30) // 30 days
}
// Check existing variant assignment
cookieName := fmt.Sprintf("pgvar_%d", exp.ID)
variantIDStr := ctx.GetSiteCookie(cookieName)
var variant *pages_model.PageVariant
if variantIDStr != "" {
variantID, parseErr := strconv.ParseInt(variantIDStr, 10, 64)
if parseErr == nil {
variant, _ = pages_model.GetVariantByID(ctx, variantID)
}
}
if variant == nil {
// Load all variants and do weighted random assignment
variants, loadErr := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
if loadErr != nil || len(variants) == 0 {
return config
}
variant = weightedRandomSelect(variants)
ctx.SetSiteCookie(cookieName, strconv.FormatInt(variant.ID, 10), 86400*30)
}
// Set A/B test template data
ctx.Data["ABTestActive"] = true
ctx.Data["ExperimentID"] = exp.ID
ctx.Data["VariantID"] = variant.ID
ctx.Data["VisitorID"] = visitorID
// Determine event tracking URL
if blogBaseURL, ok := ctx.Data["BlogBaseURL"].(string); ok && strings.Contains(blogBaseURL, "/pages/blog") {
// Path-based: use repo-scoped events URL
ctx.Data["EventTrackURL"] = strings.TrimSuffix(blogBaseURL, "/blog") + "/events"
} else {
ctx.Data["EventTrackURL"] = "/pages/events"
}
// If control variant, no config overrides needed
if variant.IsControl || variant.ConfigOverride == "" {
return config
}
// Deep-merge variant overrides onto config
merged, mergeErr := deepMergeConfig(config, variant.ConfigOverride)
if mergeErr != nil {
log.Error("Failed to merge variant config: %v", mergeErr)
return config
}
return merged
}
// generateVisitorID creates a random 16-byte hex visitor identifier.
func generateVisitorID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// weightedRandomSelect picks a variant using weighted random selection.
func weightedRandomSelect(variants []*pages_model.PageVariant) *pages_model.PageVariant {
totalWeight := 0
for _, v := range variants {
totalWeight += v.Weight
}
if totalWeight <= 0 {
return variants[0]
}
n, _ := rand.Int(rand.Reader, big.NewInt(int64(totalWeight)))
pick := int(n.Int64())
cumulative := 0
for _, v := range variants {
cumulative += v.Weight
if pick < cumulative {
return v
}
}
return variants[len(variants)-1]
}
// deepMergeConfig deep-merges a JSON config override onto a base LandingConfig.
// Only non-zero values in the override replace base values.
func deepMergeConfig(base *pages_module.LandingConfig, overrideJSON string) (*pages_module.LandingConfig, error) {
// Marshal base to JSON map
baseJSON, err := json.Marshal(base)
if err != nil {
return nil, err
}
var baseMap map[string]any
if err := json.Unmarshal(baseJSON, &baseMap); err != nil {
return nil, err
}
var overrideMap map[string]any
if err := json.Unmarshal([]byte(overrideJSON), &overrideMap); err != nil {
return nil, err
}
// Deep merge
merged := deepMerge(baseMap, overrideMap)
// Unmarshal back to LandingConfig
mergedJSON, err := json.Marshal(merged)
if err != nil {
return nil, err
}
var result pages_module.LandingConfig
if err := json.Unmarshal(mergedJSON, &result); err != nil {
return nil, err
}
return &result, nil
}
// deepMerge recursively merges src into dst.
func deepMerge(dst, src map[string]any) map[string]any {
for key, srcVal := range src {
if dstVal, ok := dst[key]; ok {
// Both are maps: recurse
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dstVal.(map[string]any)
if srcIsMap && dstIsMap {
dst[key] = deepMerge(dstMap, srcMap)
continue
}
}
dst[key] = srcVal
}
return dst
}
// detectPageLanguage determines the active language for a landing page.
// Priority: ?lang= query param > pages_lang cookie > Accept-Language header > default.
func detectPageLanguage(ctx *context.Context, config *pages_module.LandingConfig) string {
langs := config.I18n.Languages
if len(langs) == 0 {
return ""
}
defaultLang := config.I18n.DefaultLang
if defaultLang == "" {
defaultLang = "en"
}
// 1. Explicit ?lang= query parameter
if qLang := ctx.FormString("lang"); qLang != "" {
if slices.Contains(langs, qLang) {
ctx.SetSiteCookie("pages_lang", qLang, 86400*365)
return qLang
}
}
// 2. Cookie
if cLang := ctx.GetSiteCookie("pages_lang"); cLang != "" {
if slices.Contains(langs, cLang) {
return cLang
}
}
// 3. Accept-Language header
accept := ctx.Req.Header.Get("Accept-Language")
if accept != "" {
for part := range strings.SplitSeq(accept, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
// Try exact match first
if slices.Contains(langs, tag) {
return tag
}
// Try base language (e.g. "en-US" → "en")
if base, _, found := strings.Cut(tag, "-"); found {
if slices.Contains(langs, base) {
return base
}
}
}
}
return defaultLang
}
// applyLanguageOverlay loads the translation for the detected language and merges it onto config.
// Sets template data for the language switcher and returns the (possibly merged) config.
func applyLanguageOverlay(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
if len(config.I18n.Languages) == 0 {
return config
}
activeLang := detectPageLanguage(ctx, config)
defaultLang := config.I18n.DefaultLang
if defaultLang == "" {
defaultLang = "en"
}
// Set template data for language switcher
ctx.Data["LangSwitcherEnabled"] = true
ctx.Data["ActiveLang"] = activeLang
ctx.Data["AvailableLanguages"] = config.I18n.Languages
ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames()
// If active language is the default, no overlay needed
if activeLang == defaultLang || activeLang == "" {
return config
}
// Load translation overlay from DB
translation, err := pages_model.GetTranslation(ctx, repo.ID, activeLang)
if err != nil {
log.Error("Failed to load translation for %s: %v", activeLang, err)
return config
}
if translation == nil || translation.ConfigJSON == "" {
return config
}
// Deep-merge translation overlay onto config
merged, err := deepMergeConfig(config, translation.ConfigJSON)
if err != nil {
log.Error("Failed to merge translation config for %s: %v", activeLang, err)
return config
}
return merged
}
// ApproveExperiment handles the email approval link for an A/B test experiment
func ApproveExperiment(ctx *context.Context) {
handleExperimentAction(ctx, true)
}
// DeclineExperiment handles the email decline link for an A/B test experiment
func DeclineExperiment(ctx *context.Context) {
handleExperimentAction(ctx, false)
}
func handleExperimentAction(ctx *context.Context, approve bool) {
tokenStr := ctx.PathParam("token")
if tokenStr == "" {
ctx.NotFound(errors.New("missing token"))
return
}
// Extract and verify the token
expIDStr, err := pages_service.VerifyExperimentToken(ctx, tokenStr)
if err != nil {
log.Error("Invalid experiment token: %v", err)
ctx.NotFound(errors.New("invalid or expired token"))
return
}
expID, err := strconv.ParseInt(expIDStr, 10, 64)
if err != nil {
ctx.NotFound(errors.New("invalid experiment ID"))
return
}
exp, err := pages_model.GetExperimentByID(ctx, expID)
if err != nil || exp == nil {
ctx.NotFound(errors.New("experiment not found"))
return
}
if approve {
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusApproved)
ctx.Data["Title"] = "Experiment Approved"
ctx.Data["ExperimentApproved"] = true
} else {
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusPaused)
ctx.Data["Title"] = "Experiment Declined"
ctx.Data["ExperimentDeclined"] = true
}
if err != nil {
ctx.ServerError("Failed to update experiment", err)
return
}
ctx.Data["ExperimentName"] = exp.Name
ctx.HTML(http.StatusOK, "pages/experiment_result")
}
// getContentType returns the content type for a file extension
func getContentType(ext string) string {
types := map[string]string{
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}
if ct, ok := types[strings.ToLower(ext)]; ok {
return ct
}
return "application/octet-stream"
}