2
0
Files
gitcaddy-server/routers/web/pages/pages.go
logikonline 734dd895bb feat(pages): add public releases option for landing pages
Allow private repositories to enable public release downloads on their landing pages. When enabled, unauthenticated users can download release attachments without accessing the repository. Adds download sections to all landing page templates with styling.
2026-03-06 18:04:35 -05:00

427 lines
10 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"errors"
"html/template"
"net/http"
"path"
"strings"
"time"
"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/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
}
}
// Render the landing page
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
}
// renderLandingPage renders the landing page based on the template
func renderLandingPage(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) {
// Set up context data
ctx.Data["Repository"] = repo
ctx.Data["Config"] = config
ctx.Data["Title"] = getPageTitle(repo, config)
ctx.Data["PageIsPagesLanding"] = true
// Load README content
readme, err := loadReadmeContent(ctx, repo)
if err != nil {
log.Warn("Failed to load README: %v", err)
}
ctx.Data["ReadmeContent"] = readme
// Load repo stats
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["PublicReleases"] = config.Advanced.PublicReleases
// Select template based on config
tpl := selectTemplate(config.Template)
ctx.HTML(http.StatusOK, tpl)
}
// 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
}
// Render the landing page
renderLandingPage(ctx, repo, config)
}
// 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)
}
// 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"
}