Implement internationalization system for landing pages: - Database model for storing language-specific translations - Language configuration with default and enabled languages - Language switcher in navigation across all templates - Translation management UI in settings - Support for 15 languages including English, Spanish, German, French, Japanese, Chinese - Auto-detection and manual language selection - AI-powered translation generation capability
1011 lines
27 KiB
Go
1011 lines
27 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/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
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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.LogoURL
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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"
|
|
}
|