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.
427 lines
10 KiB
Go
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"
|
|
}
|