Go · 4433 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package handlers
4
5 import (
6 "encoding/json"
7 "encoding/xml"
8 "fmt"
9 "html/template"
10 "log/slog"
11 "net/http"
12 "net/url"
13 "strings"
14
15 "github.com/tenseleyFlow/shithub/internal/web/middleware"
16 "github.com/tenseleyFlow/shithub/internal/web/render"
17 )
18
19 const defaultMetaDescription = "shithub is an AGPL self-hosted GitHub alternative: Git repositories, pull requests, issues, Actions-style CI, organizations, and code search without Copilot."
20
21 type marketingHandler struct {
22 render *render.Renderer
23 baseURL string
24 logger *slog.Logger
25 }
26
27 type crawlerHandler struct {
28 baseURL string
29 }
30
31 var sitemapPaths = []string{
32 "/",
33 "/about",
34 "/explore",
35 "/trending",
36 }
37
38 func (h marketingHandler) serveAbout(w http.ResponseWriter, r *http.Request) {
39 data := map[string]any{
40 "Title": "GitHub alternative for self-hosted Git teams",
41 "MetaDescription": "shithub is a self-hosted git forge and GitHub alternative for teams that want familiar pull requests, issues, Actions-style CI, and open-source control without Copilot.",
42 "CanonicalURL": canonicalURL(h.baseURL, r, "/about"),
43 "OGTitle": "shithub: self-hosted GitHub alternative",
44 "OGDescription": "An AGPL git forge with GitHub-style repositories, pull requests, issues, organizations, code search, and Actions-style CI without Copilot.",
45 "StructuredData": organizationStructuredData(publicBaseURL(h.baseURL, r)),
46 "GlobalSearchQuery": "",
47 "Viewer": middleware.CurrentUserFromContext(r.Context()),
48 "CSRFToken": middleware.CSRFTokenForRequest(r),
49 "Repo": nil,
50 "Org": nil,
51 }
52 if err := h.render.RenderPage(w, r, "about", data); err != nil {
53 h.logger.Error("render about", "error", err)
54 http.Error(w, "internal server error", http.StatusInternalServerError)
55 }
56 }
57
58 func (h crawlerHandler) serveRobots(w http.ResponseWriter, r *http.Request) {
59 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
60 w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
61
62 sitemap := canonicalURL(h.baseURL, r, "/sitemap.xml")
63 fmt.Fprintln(w, "User-agent: *")
64 fmt.Fprintln(w, "Allow: /")
65 fmt.Fprintln(w, "Disallow: /admin")
66 fmt.Fprintln(w, "Disallow: /api/")
67 fmt.Fprintln(w, "Disallow: /internal/")
68 fmt.Fprintln(w, "Disallow: /notifications")
69 fmt.Fprintln(w, "Disallow: /search")
70 fmt.Fprintln(w, "Disallow: /settings")
71 fmt.Fprintln(w, "Disallow: /*.git/")
72 if sitemap != "" {
73 fmt.Fprintln(w, "Sitemap: "+sitemap) // #nosec G705 -- text/plain robots body; request-host fallback rejects control characters.
74 }
75 }
76
77 func (h crawlerHandler) serveSitemap(w http.ResponseWriter, r *http.Request) {
78 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
79 w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
80
81 fmt.Fprintln(w, xml.Header+`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
82 for _, p := range sitemapPaths {
83 loc := canonicalURL(h.baseURL, r, p)
84 if loc == "" {
85 continue
86 }
87 fmt.Fprint(w, " <url><loc>")
88 _ = xml.EscapeText(w, []byte(loc))
89 fmt.Fprintln(w, "</loc></url>")
90 }
91 fmt.Fprintln(w, "</urlset>")
92 }
93
94 func canonicalURL(configuredBase string, r *http.Request, p string) string {
95 base := publicBaseURL(configuredBase, r)
96 if base == "" {
97 return ""
98 }
99 if p == "" {
100 p = "/"
101 }
102 if !strings.HasPrefix(p, "/") {
103 p = "/" + p
104 }
105 return base + p
106 }
107
108 func publicBaseURL(configuredBase string, r *http.Request) string {
109 if base := strings.TrimRight(strings.TrimSpace(configuredBase), "/"); base != "" {
110 return base
111 }
112 if r == nil || r.Host == "" {
113 return ""
114 }
115 host := strings.TrimSpace(r.Host)
116 if host == "" || strings.ContainsAny(host, "\r\n\t /\\") {
117 return ""
118 }
119 scheme := "http"
120 if r.TLS != nil {
121 scheme = "https"
122 }
123 return (&url.URL{Scheme: scheme, Host: host}).String()
124 }
125
126 func organizationStructuredData(baseURL string) template.JS {
127 if baseURL == "" {
128 return ""
129 }
130 payload := map[string]any{
131 "@context": "https://schema.org",
132 "@type": "Organization",
133 "name": "shithub",
134 "url": baseURL,
135 "logo": baseURL + "/static/logo/shithub-mark.svg",
136 "description": defaultMetaDescription,
137 "sameAs": []string{
138 "https://github.com/tenseleyFlow/shithub",
139 },
140 }
141 raw, _ := json.Marshal(payload)
142 return template.JS(raw) // #nosec G203 -- payload is marshaled from server-owned constants and URLs.
143 }
144