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
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.
1150 lines
31 KiB
Go
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"
|
|
}
|