2
0
Files
gitcaddy-server/routers/web/pages/pages.go
logikonline 433214fb91
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m56s
Build and Release / Unit Tests (push) Successful in 8m52s
Build and Release / Lint (push) Successful in 9m21s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Failing after 1s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Failing after 1s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 4m54s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m27s
Build and Release / Build Binary (linux/arm64) (push) Failing after 13m25s
feat(pages): add app store links to downloads section
Add GooglePlayID and AppStoreID config fields to display app store download buttons on landing pages. Shows "App Stores" section with Google Play and/or App Store badges when IDs are configured. Useful for mobile apps distributed via stores instead of direct downloads. Includes branded SVG icons for both stores. Applies to all four page templates.
2026-03-16 02:04:33 -04:00

1150 lines
31 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"html/template"
"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/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"
)
// 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 asset requests (gallery images, custom assets)
if assetPath, found := strings.CutPrefix(requestPath, "/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"
}
ctx.Data["PageURL"] = scheme + "://" + ctx.Req.Host + ctx.Req.URL.RequestURI()
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 /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 "/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 "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)
}
// 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
content := make([]byte, entry.Blob().Size())
_, err = reader.Read(content)
if err != nil && err.Error() != "EOF" {
ctx.ServerError("Failed to read asset", err)
return
}
_, _ = ctx.Resp.Write(content)
}
// 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"
}