2
0
Files
gitcaddy-server/routers/web/pages/pages.go
logikonline f3eba7dd34 feat(pages): add 5 new landing page templates
Add Documentation First, Developer Tool, Visual Showcase, CLI Terminal, and Architecture Deep Dive templates. Brings total templates to 9. Each template has unique design language and target audience: Documentation First for docs-heavy projects, Developer Tool for technical products, Visual Showcase for design/media projects, CLI Terminal for command-line tools, Architecture Deep Dive for technical deep-dives. Updates template display names for clarity.
2026-03-16 22:18:53 -04:00

1254 lines
34 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"
}
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"
}