package forge
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html"
"html/template"
"io"
"io/fs"
"log"
"math/rand/v2"
"mime"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
mathjax "github.com/litao91/goldmark-mathjax"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
gmhtml "github.com/yuin/goldmark/renderer/html"
"gitpocket/web"
)
var (
templateFS = web.Templates
staticFS = web.Static
)
type Server struct {
mailer *Mailer
version string
limiter *limiter
requests *buckets
heavy *buckets
uploads *limiter
posts *buckets
store *Store
root string
host string
name string
sshUser string
pages map[string]*template.Template
brand map[string][]byte
markdown goldmark.Markdown
policy *bluemonday.Policy
}
type page struct {
Version string
Fluid bool
Title string
User *User
Host string
Name string
SSHUser string
Error string
Math bool
Data any
Registration string
Admin bool
Logo bool
Icon bool
Theme string
Unread int
Inbox []Notification
}
func newServer(store *Store, root, host, name string, sshUser string) *Server {
s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour), posts: newBuckets(0.2, 60)}
funcs := template.FuncMap{
"short": func(h string) string {
if len(h) > 10 {
return h[:10]
}
return h
},
"ago": ago,
"size": humanSize,
"date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") },
"lines": func(b []byte) []string {
s := strings.TrimSuffix(string(b), "\n")
return strings.Split(s, "\n")
},
"inc": func(i int) int { return i + 1 },
"perm": perm,
"signer": s.signer,
"lang": func(r Repo) language {
if l := s.repoLanguages(r); len(l) > 0 {
return l[0]
}
return language{}
},
"bare": func(u string) string {
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://"), "/")
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"person": func(email string) string {
if u, ok := s.personByEmail(email); ok {
return u.Name
}
return ""
},
"dict": func(kv ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
if k, ok := kv[i].(string); ok {
m[k] = kv[i+1]
}
}
return m
},
"join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
}
names, _ := fs.Glob(templateFS, "templates/*.tmpl")
for _, name := range names {
base := filepath.Base(name)
if base == "layout.tmpl" {
continue
}
if base == "meta.tmpl" {
continue
}
s.pages[strings.TrimSuffix(base, ".tmpl")] = template.Must(template.New("layout.tmpl").Funcs(funcs).ParseFS(templateFS, "templates/layout.tmpl", "templates/meta.tmpl", name))
}
s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax,
highlighting.NewHighlighting(highlighting.WithGuessLanguage(true), highlighting.WithFormatOptions(chromahtml.WithClasses(true)))),
goldmark.WithRendererOptions(gmhtml.WithUnsafe()))
s.policy = bluemonday.UGCPolicy()
s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code")
s.policy.AllowElements("video", "source")
s.policy.AllowAttrs("src", "type").OnElements("video", "source")
s.policy.AllowAttrs("controls", "muted", "loop", "playsinline", "poster", "width", "height").OnElements("video")
s.policy.AllowAttrs("align").Matching(regexp.MustCompile(`^(center|left|right)$`)).OnElements("p", "div", "h1", "h2", "h3", "h4")
s.writeSigners()
s.writeKeyring()
s.installHooks()
sum := sha256.New()
fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
b, _ := staticFS.ReadFile(path)
sum.Write(b)
}
return nil
})
sum.Write(highlightCSS())
s.brand = map[string][]byte{}
for _, f := range []string{"logo.png", "icon.png"} {
if b, err := os.ReadFile(filepath.Join(root, f)); err == nil {
s.brand[f] = b
sum.Write(b)
}
}
s.version = hex.EncodeToString(sum.Sum(nil))[:12]
return s
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) {
p.Host = s.host
p.Name = s.name
p.SSHUser = s.sshUser
p.Version = s.version
p.Logo = s.brand["logo.png"] != nil
p.Icon = s.brand["icon.png"] != nil
p.Registration = s.store.Registration()
if d, ok := p.Data.(map[string]any); ok && d["File"] == true {
p.Fluid = true
}
if p.User == nil {
p.User = s.current(r)
}
if p.User != nil {
p.Admin, p.Theme, p.Unread = s.store.Viewer(p.User.ID)
p.Inbox = s.store.Notifications(p.User.ID, 12)
}
t, ok := s.pages[name]
if !ok {
http.Error(w, "missing template "+name, 500)
return
}
var buf bytes.Buffer
if err := t.Execute(&buf, p); err != nil {
log.Printf("render %s: %v", name, err)
http.Error(w, "template error", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
if code >= 500 {
log.Printf("%s %s: %s", r.Method, r.URL.Path, msg)
msg = "Something went wrong"
}
w.WriteHeader(code)
s.render(w, r, "error", page{Title: msg, Error: msg})
}
func (s *Server) current(r *http.Request) *User {
c, err := r.Cookie("session")
if err != nil {
return nil
}
u, err := s.store.SessionUser(c.Value)
if err != nil {
return nil
}
return &u
}
func (s *Server) handler() http.Handler {
mux := http.NewServeMux()
static, _ := fs.Sub(staticFS, "static")
files := http.StripPrefix("/static/", http.FileServerFS(static))
mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) {
s.staticCache(w, r)
files.ServeHTTP(w, r)
})
css := highlightCSS()
mux.HandleFunc("GET /static/highlight.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
s.staticCache(w, r)
w.Write(css)
})
for name, b := range s.brand {
mux.HandleFunc("GET /static/site/"+name, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
s.staticCache(w, r)
w.Write(b)
})
}
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /projects", s.projects)
mux.HandleFunc("GET /search", s.find)
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.login)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /login/totp", s.totpForm)
mux.HandleFunc("POST /login/totp", s.totpLogin)
mux.HandleFunc("GET /reset", s.resetForm)
mux.HandleFunc("POST /reset", s.resetRequest)
mux.HandleFunc("GET /reset/{token}", s.resetTokenForm)
mux.HandleFunc("POST /reset/{token}", s.resetPassword)
mux.HandleFunc("GET /verify/{token}", s.verifyEmail)
mux.HandleFunc("POST /settings/email", s.setEmail)
mux.HandleFunc("POST /settings/notify", s.setNotify)
mux.HandleFunc("POST /settings/totp", s.setupTOTP)
mux.HandleFunc("POST /settings/totp/enable", s.enableTOTP)
mux.HandleFunc("POST /settings/totp/disable", s.disableTOTP)
mux.HandleFunc("GET /register", s.registerForm)
mux.HandleFunc("POST /register", s.register)
mux.HandleFunc("GET /create", s.createForm)
mux.HandleFunc("POST /create", s.create)
mux.HandleFunc("GET /import", s.importForm)
mux.HandleFunc("POST /import", s.importRepo)
mux.HandleFunc("GET /settings", s.settings)
mux.HandleFunc("GET /settings/security", s.security)
mux.HandleFunc("GET /settings/keys", s.keysPage)
mux.HandleFunc("POST /attachments", s.upload)
mux.HandleFunc("GET /attachments/{name}", s.attachment)
mux.HandleFunc("POST /preview", s.preview)
mux.HandleFunc("/notifications", s.inbox)
mux.HandleFunc("GET /notifications/{id}", s.openNotification)
mux.HandleFunc("GET /settings/appearance", s.appearance)
mux.HandleFunc("POST /settings/appearance", s.appearance)
mux.HandleFunc("GET /settings/tokens", s.tokensPage)
mux.HandleFunc("POST /settings/sessions", s.endSessions)
mux.HandleFunc("POST /settings/keys", s.addKey)
mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey)
mux.HandleFunc("POST /settings/pgp", s.addPGP)
mux.HandleFunc("POST /settings/pgp/{id}/delete", s.deletePGP)
mux.HandleFunc("POST /settings/tokens", s.addToken)
mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken)
mux.HandleFunc("POST /settings/password", s.password)
mux.HandleFunc("POST /settings/username", s.rename)
mux.HandleFunc("POST /settings/profile", s.profile)
mux.HandleFunc("POST /settings/avatar", s.avatar)
mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar)
mux.HandleFunc("/admin", s.admin)
mux.HandleFunc("/orgs/new", s.newOrg)
mux.HandleFunc("/", s.route)
cop := http.NewCrossOriginProtection()
return secure(s.throttle(cop.Handler(mux)))
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
user := s.current(r)
recent, err := s.store.PublicRepos("", "")
if err != nil {
s.fail(w, r, 500, err.Error())
return
}
recent = s.byUpdate(recent)
featured, _ := s.store.FeaturedRepos()
if len(featured) == 0 {
featured = slices.Clone(recent[:min(len(recent), 6)])
}
var repos, shared []Repo
if user != nil {
repos, _ = s.store.ReposByOwner(user.ID, true)
shared, _ = s.store.SharedRepos(user.ID)
repos, shared = s.byUpdate(repos), s.byUpdate(shared)
recent = slices.DeleteFunc(recent, func(r Repo) bool { return r.Owner.ID == user.ID })
}
s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent[:min(len(recent), 10)], "Featured": featured}})
}
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "login", page{Title: "Log in"})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
name := strings.TrimSpace(r.FormValue("name"))
if s.limiter.blocked(ip) || s.limiter.blocked("~"+name) {
w.WriteHeader(429)
s.render(w, r, "login", page{Title: "Log in", Error: "Too many attempts, try again in a few minutes"})
return
}
u, ok := s.store.CheckPassword(name, r.FormValue("password"))
if !ok {
s.limiter.fail(ip)
s.limiter.fail("~" + name)
w.WriteHeader(401)
s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"})
return
}
if s.store.Account(u.ID).TOTP != "" {
token, _ := s.store.CreateMailToken(u.ID, "totp", "")
http.SetCookie(w, &http.Cookie{Name: "pending", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600})
http.Redirect(w, r, "/login/totp", http.StatusSeeOther)
return
}
s.startSession(w, u)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) startSession(w http.ResponseWriter, u User) {
id, err := s.store.CreateSession(u.ID)
if err != nil {
return
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: id, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 90 * 86400})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("session"); err == nil {
s.store.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite"), "Mail": s.mailer != nil}})
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
invite := strings.TrimSpace(r.FormValue("invite"))
retry := func(code int, msg string) {
w.WriteHeader(code)
s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite, "Mail": s.mailer != nil, "Email": r.FormValue("email"), "Name": r.FormValue("name")}})
}
mode := s.store.Registration()
if mode == "closed" {
retry(403, "Registration is closed")
return
}
ip := clientIP(r)
if s.limiter.blocked(ip) || s.limiter.blocked("register:"+ip) {
retry(429, "Too many attempts, try again in a few minutes")
return
}
name := strings.TrimSpace(r.FormValue("name"))
if err := checkCredentials(name, r.FormValue("password")); err != nil {
retry(400, err.Error())
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
if !validEmail(email) {
retry(400, "A valid email address is needed to register")
return
}
if mode == "invite" && !s.store.UseInvite(invite) {
s.limiter.fail(ip)
retry(400, "That invite is invalid or already used")
return
}
u, err := s.store.CreateUser(name, r.FormValue("password"))
if err != nil {
retry(400, err.Error())
return
}
s.limiter.fail("register:" + ip)
s.startSession(w, u)
if email != "" && s.store.SetEmail(u.ID, email) == nil && s.mailer != nil {
go func() {
mailSlots <- struct{}{}
defer func() { <-mailSlots }()
if err := s.sendVerification(u, email); err != nil {
log.Printf("verification mail for ~%s: %v", u.Name, err)
}
}()
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) require(w http.ResponseWriter, r *http.Request) *User {
u := s.current(r)
if u == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return u
}
func (s *Server) createForm(w http.ResponseWriter, r *http.Request) {
if u := s.require(w, r); u != nil {
s.render(w, r, "create", page{Title: "New repository", User: u, Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.URL.Query().Get("owner"), "Suggest": suggestName()}})
}
}
func (s *Server) create(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
owner := *u
if o := r.FormValue("owner"); o != "" && o != u.Name {
org, err := s.store.UserByName(o)
if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
s.fail(w, r, 403, "You are not a member of that organization")
return
}
owner = org
}
name := strings.TrimSpace(r.FormValue("name"))
repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags"))
if err != nil {
w.WriteHeader(400)
s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error(), Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Suggest": suggestName(), "Owner": owner.Name, "Name": name, "Description": r.FormValue("description"), "Tags": r.FormValue("tags")}})
return
}
dir := s.repoDir(repo)
err = os.MkdirAll(filepath.Dir(dir), 0o755)
if err == nil {
err = initRepo(dir)
}
if err != nil {
log.Printf("create %s: %v", repo.Path(), err)
os.RemoveAll(dir)
s.store.DeleteRepo(repo.ID)
s.fail(w, r, 500, "Could not create the repository")
return
}
installHook(s.root, dir)
http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
}
func (s *Server) signer(c Commit) string {
switch c.Signature {
case "G", "U":
default:
return ""
}
if c.Key != "" {
if u, ok := s.store.UserByFingerprint(c.Key); ok {
return u.Name
}
}
if c.PGP != "" {
if u, ok := s.store.UserByPGP(c.PGP); ok {
return u.Name
}
}
return ""
}
func (s *Server) writeKeyring() {
home := filepath.Join(s.root, "gnupg")
if err := os.MkdirAll(home, 0o700); err != nil {
return
}
keys, err := s.store.AllPGP()
if err != nil {
return
}
os.Remove(filepath.Join(home, "pubring.kbx"))
os.Remove(filepath.Join(home, "pubring.kbx~"))
for _, k := range keys {
cmd := exec.Command("gpg", "--batch", "--quiet", "--import")
cmd.Env = []string{"GNUPGHOME=" + home, "PATH=" + os.Getenv("PATH")}
cmd.Stdin = strings.NewReader(k)
cmd.Run()
}
exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run()
gnupgHome = home
}
func pgpFingerprint(armored string) (string, error) {
cmd := exec.Command("gpg", "--batch", "--with-colons", "--import-options", "show-only", "--import")
dir, err := os.MkdirTemp("", "gitpocket-gpg")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
cmd.Env = []string{"GNUPGHOME=" + dir, "PATH=" + os.Getenv("PATH")}
cmd.Stdin = strings.NewReader(armored)
out, err := cmd.Output()
if err != nil {
return "", errors.New("That is not a valid armored PGP public key")
}
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "fpr:") {
f := strings.Split(line, ":")
if len(f) > 9 && f[9] != "" {
return strings.ToUpper(f[9]), nil
}
}
}
return "", errors.New("That is not a valid armored PGP public key")
}
func (s *Server) writeSigners() {
keys, err := s.store.AllKeys()
if err != nil {
return
}
var b strings.Builder
for _, k := range keys {
b.WriteString("* namespaces=\"git\" " + k + "\n")
}
path := filepath.Join(s.root, "signers")
if err := os.WriteFile(path+".tmp", []byte(b.String()), 0o644); err == nil {
if os.Rename(path+".tmp", path) == nil {
signersFile = path
}
}
}
func visibility(v string) int {
switch v {
case "private":
return Private
case "unlisted":
return Unlisted
}
return Public
}
func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("search"))
if tag := strings.TrimSpace(r.URL.Query().Get("tag")); tag != "" {
q = strings.TrimSpace(q + " tag:" + tag)
}
http.Redirect(w, r, "/search?q="+url.QueryEscape(q), http.StatusMovedPermanently)
}
func (s *Server) byUpdate(repos []Repo) []Repo {
for i := range repos {
repos[i].Updated = repoUpdated(s.repoDir(repos[i]))
}
slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) })
return repos
}
func repoUpdated(dir string) time.Time {
var latest time.Time
consider := func(p string) {
if info, err := os.Stat(p); err == nil && info.ModTime().After(latest) {
latest = info.ModTime()
}
}
consider(filepath.Join(dir, "packed-refs"))
for _, sub := range []string{"heads", "tags"} {
filepath.WalkDir(filepath.Join(dir, "refs", sub), func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
consider(p)
}
return nil
})
}
if latest.IsZero() {
consider(dir)
}
return latest
}
func (s *Server) repoDir(repo Repo) string {
return filepath.Join(s.root, "repos", repo.Owner.Name, repo.Name+".git")
}
func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
used := usage(filepath.Join(s.root, "repos", u.Name))
s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"),
Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}})
}
func (s *Server) security(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
a := s.store.Account(u.ID)
s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security", "Account": a, "Mail": s.mailer != nil}})
}
func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
keys, _ := s.store.Keys(u.ID)
pgp, _ := s.store.PGPKeys(u.ID)
s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys, "PGP": pgp}})
}
func (s *Server) addPGP(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
armored := strings.TrimSpace(r.FormValue("key"))
if len(armored) > 64<<10 {
http.Redirect(w, r, "/settings/keys?error=Key+too+large", http.StatusSeeOther)
return
}
fp, err := pgpFingerprint(armored)
if err == nil {
err = s.store.AddPGP(u.ID, fp, armored)
}
if err != nil {
http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
s.writeKeyring()
http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
}
func (s *Server) deletePGP(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
s.store.DeletePGP(u.ID, id)
s.writeKeyring()
http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
}
func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
tokens, _ := s.store.Tokens(u.ID)
s.render(w, r, "tokens", page{Title: "Tokens", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "tokens", "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}})
}
func (s *Server) appearance(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
if r.Method == "POST" {
if err := s.store.SetTheme(u.ID, r.FormValue("theme")); err != nil {
http.Redirect(w, r, "/settings/appearance?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/appearance", http.StatusSeeOther)
return
}
s.render(w, r, "appearance", page{Title: "Appearance", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "appearance"}})
}
func (s *Server) endSessions(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
s.store.DeleteSessions(u.ID)
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) subject(w http.ResponseWriter, r *http.Request) (*User, string) {
u := s.require(w, r)
if u == nil {
return nil, ""
}
name := r.FormValue("org")
if name == "" {
return u, "/settings"
}
org, err := s.store.UserByName(name)
if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
s.fail(w, r, 403, "You are not a member of that organization")
return nil, ""
}
return &org, "/~" + org.Name + "/settings"
}
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
u, back := s.subject(w, r)
if u == nil {
return
}
links := func(vals []string) string {
var out []string
for _, v := range vals {
if v = strings.TrimSpace(v); v != "" && len(v) <= 200 && !strings.ContainsAny(v, "\r\n") {
out = append(out, v)
}
}
return strings.Join(out, "\n")
}
field := func(name string, max int) string {
v := strings.TrimSpace(r.FormValue(name))
if len(v) > max {
v = v[:max]
}
return v
}
p := Profile{Display: field("display", 100), Bio: field("bio", 4000), Location: field("location", 100), Workplace: field("workplace", 100), Website: links(r.Form["website"])}
if err := s.store.UpdateProfile(u.ID, p); err != nil {
http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
}
func (s *Server) avatarPath(id int64) string {
return filepath.Join(s.root, "avatars", strconv.FormatInt(id, 10))
}
func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
u, back := s.subject(w, r)
if u == nil {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
f, _, err := r.FormFile("avatar")
if err != nil {
http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
return
}
x, _ := strconv.Atoi(r.FormValue("x"))
y, _ := strconv.Atoi(r.FormValue("y"))
size, _ := strconv.Atoi(r.FormValue("size"))
out, err := cropAvatar(data, x, y, size)
if err == nil {
if err = os.MkdirAll(filepath.Join(s.root, "avatars"), 0o755); err == nil {
err = os.WriteFile(s.avatarPath(u.ID), out, 0o644)
}
}
if err != nil {
http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
s.store.SetAvatar(u.ID, time.Now().Unix())
http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
}
func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) {
u, back := s.subject(w, r)
if u == nil {
return
}
os.Remove(s.avatarPath(u.ID))
s.store.SetAvatar(u.ID, 0)
http.Redirect(w, r, back, http.StatusSeeOther)
}
func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) {
data, err := os.ReadFile(s.avatarPath(owner.ID))
age := "86400"
if err != nil {
data, age = identicon(owner.Name), "3600"
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "public, max-age="+age)
w.Write(data)
}
func (s *Server) addKey(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
if err := s.store.AddKey(u.ID, strings.TrimSpace(r.FormValue("title")), r.FormValue("key")); err != nil {
http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
s.writeSigners()
http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
}
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
s.store.DeleteKey(u.ID, id)
s.writeSigners()
http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
}
func (s *Server) addToken(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title")))
if err != nil {
http.Redirect(w, r, "/settings/tokens?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/tokens?token="+token, http.StatusSeeOther)
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
s.store.DeleteToken(u.ID, id)
http.Redirect(w, r, "/settings/tokens", http.StatusSeeOther)
}
func (s *Server) password(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok {
s.limiter.fail(clientIP(r))
http.Redirect(w, r, "/settings/security?error=Current+password+is+wrong", http.StatusSeeOther)
return
}
if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil {
http.Redirect(w, r, "/settings/security?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
return
}
if c, err := r.Cookie("session"); err == nil {
s.store.KeepOnlySession(u.ID, c.Value)
}
http.Redirect(w, r, "/settings/security", http.StatusSeeOther)
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/~") {
s.fail(w, r, 404, "Page not found")
return
}
parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/~"), "/", 3)
if len(parts) == 1 && (strings.HasSuffix(parts[0], ".keys") || strings.HasSuffix(parts[0], ".pgp")) {
s.publicKeys(w, r, parts[0])
return
}
owner, err := s.store.UserByName(parts[0])
if err != nil {
if renamed, ok := s.store.Renamed(parts[0]); ok {
target := "/~" + renamed.Name + strings.TrimPrefix(r.URL.Path, "/~"+parts[0])
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
code := http.StatusMovedPermanently
if r.Method != "GET" && r.Method != "HEAD" {
code = http.StatusPermanentRedirect
}
http.Redirect(w, r, target, code)
return
}
s.fail(w, r, 404, "No such user")
return
}
user := s.current(r)
if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") {
org := s.store.IsOrg(owner.ID)
member := user != nil && (user.ID == owner.ID || s.store.IsMember(owner.ID, user.ID))
repos, err := s.store.ReposByOwner(owner.ID, member)
if err != nil {
s.fail(w, r, 500, err.Error())
return
}
repos = s.byUpdate(repos)
profile := s.store.Profile(owner.ID)
var bio template.HTML
if profile.Bio != "" {
bio = s.renderMarkdown([]byte(profile.Bio))
}
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year < 1970 || year > time.Now().Year() {
year = 0
}
data := map[string]any{"Owner": owner, "Repos": pinnedRepos(repos, s.store.Pins(owner.ID)), "Total": len(repos), "Profile": profile, "Bio": bio, "Count": s.store.StarredCount(owner.ID), "Activity": s.activity(owner, year), "Member": member}
if org {
var people []userHit
for _, m := range s.store.Members(owner.ID) {
people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)})
}
data["Members"] = people
data["Followers"], _ = s.store.FollowCounts(owner.ID)
data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID)
s.render(w, r, "org", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: data})
return
}
var orgs []userHit
for _, o := range s.store.Orgs(owner.ID) {
orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)})
}
data["Orgs"] = orgs
data["Followers"], data["Following"] = s.store.FollowCounts(owner.ID)
data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID)
intro := s.profileReadme(owner)
data["Intro"] = intro
if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name {
if place, err := s.store.UserByName(name); err == nil {
data["Workplace"] = place.Name
}
}
s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio) || hasMath(intro.HTML), Data: data})
return
}
name := parts[1]
if name == "avatar" && len(parts) == 2 {
s.serveAvatar(w, r, owner)
return
}
if name == "feed" && len(parts) == 2 && r.Method == "GET" {
s.userFeed(w, r, owner)
return
}
if (name == "settings" || name == "members") && len(parts) == 2 && s.store.IsOrg(owner.ID) {
s.orgSettings(w, r, owner, name)
return
}
if (name == "followers" || name == "following" && !s.store.IsOrg(owner.ID)) && len(parts) == 2 && r.Method == "GET" {
s.follows(w, r, owner, user, name)
return
}
if name == "follow" && len(parts) == 2 && r.Method == "POST" {
s.follow(w, r, owner)
return
}
if name == "repos" && len(parts) == 2 && r.Method == "GET" {
s.repos(w, r, owner, user)
return
}
if name == "stars" && len(parts) == 2 && r.Method == "GET" {
starred, _ := s.store.StarredRepos(owner.ID)
s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}})
return
}
rest := ""
if len(parts) == 3 {
rest = parts[2]
}
if strings.HasSuffix(name, ".git") || isSmart(r, rest) {
s.smart(w, r, owner, strings.TrimSuffix(name, ".git"), rest)
return
}
repo, err := s.store.RepoByPath(owner.Name, name)
if err != nil {
if msg := failedImport(owner.Name + "/" + name); msg != "" && rest == "import" {
s.render(w, r, "message", page{Title: "Import failed", User: user, Data: map[string]any{"Heading": "Import of ~" + owner.Name + "/" + name + " failed", "Text": msg + ". Nothing was kept, so you can try again from the import page."}})
return
}
if !s.redirectMoved(w, r, owner.Name, name, user, http.StatusMovedPermanently) {
s.fail(w, r, 404, "No such repository")
}
return
}
if !s.store.CanRead(repo, user) {
s.fail(w, r, 404, "No such repository")
return
}
switch {
case rest == "" && r.Method == "GET":
s.summary(w, r, repo)
case rest == "settings" && r.Method == "GET":
s.repoSettings(w, r, repo)
case rest == "settings" && r.Method == "POST":
s.updateRepo(w, r, repo)
case rest == "delete" && r.Method == "POST":
s.deleteRepo(w, r, repo)
case rest == "collaborators" && r.Method == "POST":
s.collaborators(w, r, repo)
case rest == "labels" && r.Method == "POST":
s.manageLabels(w, r, repo)
case rest == "protection" && r.Method == "POST":
s.manageProtection(w, r, repo)
case strings.HasPrefix(rest, "edit/"):
s.edit(w, r, repo, strings.TrimPrefix(rest, "edit/"), false)
case strings.HasPrefix(rest, "new/"):
s.edit(w, r, repo, strings.TrimPrefix(rest, "new/"), true)
case strings.HasPrefix(rest, "remove/") && r.Method == "POST":
s.removeFile(w, r, repo, strings.TrimPrefix(rest, "remove/"))
case rest == "rename" && r.Method == "POST":
s.renameRepo(w, r, repo)
case rest == "transfer" && r.Method == "POST":
s.transferRepo(w, r, repo)
case rest == "archive" && r.Method == "POST":
s.archiveRepo(w, r, repo)
case rest == "star" && r.Method == "POST":
s.star(w, r, repo)
case strings.HasPrefix(rest, "blame/") && r.Method == "GET":
s.blame(w, r, repo, strings.TrimPrefix(rest, "blame/"))
case strings.HasPrefix(rest, "compare") && r.Method == "GET":
s.compare(w, r, repo, strings.TrimPrefix(strings.TrimPrefix(rest, "compare"), "/"))
case strings.HasPrefix(rest, "archive/") && r.Method == "GET":
s.download(w, r, repo, strings.TrimPrefix(rest, "archive/"))
case rest == "feed" && r.Method == "GET":
s.feed(w, r, repo)
case rest == "issues/feed" && r.Method == "GET":
s.issueFeed(w, r, repo)
case rest == "pulls/feed" && r.Method == "GET":
s.pullFeed(w, r, repo)
case rest == "issues" && r.Method == "GET":
s.issues(w, r, repo)
case rest == "issues/new" && r.Method == "GET":
s.newIssue(w, r, repo)
case rest == "issues/new" && r.Method == "POST":
s.createIssue(w, r, repo)
case strings.HasPrefix(rest, "issues/"):
s.issue(w, r, repo, strings.TrimPrefix(rest, "issues/"))
case rest == "refs" && r.Method == "GET":
s.refs(w, r, repo)
case strings.HasPrefix(rest, "tree/") && r.Method == "GET":
s.tree(w, r, repo, strings.TrimPrefix(rest, "tree/"))
case strings.HasPrefix(rest, "blob/") && r.Method == "GET":
s.tree(w, r, repo, strings.TrimPrefix(rest, "blob/"))
case strings.HasPrefix(rest, "raw/") && r.Method == "GET":
s.raw(w, r, repo, strings.TrimPrefix(rest, "raw/"))
case strings.HasPrefix(rest, "log/") && r.Method == "GET":
s.log(w, r, repo, strings.TrimPrefix(rest, "log/"))
case rest == "log" && r.Method == "GET":
s.log(w, r, repo, "")
case strings.HasPrefix(rest, "commit/") && r.Method == "GET":
s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/"))
case rest == "search" && r.Method == "GET":
s.search(w, r, repo)
case rest == "pulls" && r.Method == "GET":
s.pulls(w, r, repo)
case rest == "pulls/new" && r.Method == "GET":
s.newPull(w, r, repo)
case rest == "pulls/new" && r.Method == "POST":
s.createPull(w, r, repo)
case strings.HasPrefix(rest, "pulls/"):
s.pull(w, r, repo, strings.TrimPrefix(rest, "pulls/"))
case rest == "import" && r.Method == "GET":
s.importProgress(w, r, repo)
case rest == "claim":
s.claim(w, r, repo)
case rest == "fork" && r.Method == "POST":
s.fork(w, r, repo)
default:
s.fail(w, r, 404, "Page not found")
}
}
func (s *Server) publicKeys(w http.ResponseWriter, r *http.Request, spec string) {
name, kind, _ := strings.Cut(spec, ".")
u, err := s.store.UserByName(name)
if err != nil {
http.NotFound(w, r)
return
}
ssh, pgp := s.store.PublicKeys(u.ID)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
list := ssh
if kind == "pgp" {
list = pgp
}
for _, k := range list {
fmt.Fprintln(w, k)
}
}
type repoPage struct {
Repo Repo
Ref string
Commit string
Owner bool
Empty bool
Tab string
Open int
OpenIssues int
Parent *Repo
User *User
IsOwner bool
Stars int
Starred bool
}
func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
g := Git{Dir: s.repoDir(repo)}
user := s.current(r)
rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: s.store.Owns(repo, user), Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: repo.Stars}
if user != nil {
rp.Starred = s.store.Starred(user.ID, repo.ID)
}
if repo.Parent != 0 {
if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private {
rp.Parent = &parent
}
}
head := g.Head()
if ref == "" {
ref = head
}
commit, known := s.refTable(g).byName[ref]
var err error
if !known {
commit, err = g.Resolve(ref)
}
if err != nil {
if g.Empty() {
rp.Empty = true
rp.Ref = head
return rp, g, nil
}
return rp, g, err
}
rp.Ref = ref
rp.Commit = commit
return rp, g, nil
}
func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
rp, g, err := s.repoPage(r, repo, "")
if err != nil {
s.fail(w, r, 404, "No such ref")
return
}
rp.Tab = "summary"
data := map[string]any{"Page": rp}
data["Pushed"] = s.recentPushes(repo, g, rp)
if !rp.Empty {
sum := s.summaryData(repo, g, rp)
data["Commits"], data["Branches"], data["Tags"] = sum.commits, sum.branches, sum.tags
data["Languages"] = topLanguages(s.repoLanguages(repo), 6)
if sum.readmeName != "" {
data["ReadmeName"] = sum.readmeName
if sum.readme != "" {
data["Readme"] = sum.readme
} else {
data["ReadmeText"] = sum.readmeText
}
}
}
s.render(w, r, "repo", page{Title: repo.Path(), Math: hasMath(data["Readme"]), Data: data})
}
var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`)
const maxRender = 4 << 20
const maxHighlight = 512 << 10
func (s *Server) renderMarkdown(src []byte) template.HTML {
return s.renderMarkdownAt(src, "", "")
}
var (
relLink = regexp.MustCompile(`(src|href|poster)="([^"]+)"`)
posterLink = regexp.MustCompile(`]*>\s*]*>\s*`)
videoImg = regexp.MustCompile(`
]*>`)
videoLine = regexp.MustCompile(`
\s*]*>[^<]*\s*
`) ) func embedVideos(out []byte) []byte { out = posterLink.ReplaceAll(out, []byte(``)) out = videoImg.ReplaceAll(out, []byte(``)) return videoLine.ReplaceAll(out, []byte(``)) } func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML { src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) var buf bytes.Buffer if err := s.markdown.Convert(src, &buf); err != nil { return template.HTML("" + template.HTMLEscapeString(string(src)) + "") } out := embedVideos(s.policy.SanitizeBytes(buf.Bytes())) if rawBase != "" { out = relLink.ReplaceAllFunc(out, func(m []byte) []byte { parts := relLink.FindSubmatch(m) attr, target := string(parts[1]), string(parts[2]) if strings.Contains(target, "://") || strings.HasPrefix(target, "/") || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") { return m } target = strings.TrimPrefix(target, "./") base := treeBase if attr != "href" { base = rawBase } return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`) }) } return template.HTML(out) } func hasMath(h any) bool { t, ok := h.(template.HTML) return ok && strings.Contains(string(t), `class="math `) } func (s *Server) splitRef(g Git, spec string) (string, string, bool) { spec = strings.Trim(spec, "/") if spec == "" { return "", "", false } parts := strings.Split(spec, "/") known := s.refTable(g).byName for i := len(parts); i >= 1; i-- { if _, ok := known[strings.Join(parts[:i], "/")]; ok { return strings.Join(parts[:i], "/"), strings.Join(parts[i:], "/"), true } } if _, err := g.Resolve(parts[0]); err == nil { return parts[0], strings.Join(parts[1:], "/"), true } for i := len(parts); i >= 2; i-- { ref := strings.Join(parts[:i], "/") if _, err := g.Resolve(ref); err == nil { return ref, strings.Join(parts[i:], "/"), true } } return "", "", false } func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such ref") return } filePath, ok = cleanPath(filePath) if !ok { s.fail(w, r, 404, "No such path") return } rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "tree" crumbs := strings.Split(filePath, "/") if filePath == "" { crumbs = nil } data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Branches": s.refTable(g).branches()} meta := treeInfo(g, rp.Commit, filePath) if meta.last != nil { data["LastCommit"] = *meta.last } if meta.entry != nil { data["Entry"] = meta.entry } switch meta.kind { case "tree", "commit": if meta.entries == nil { s.fail(w, r, 404, "No such path") return } data["Entries"] = meta.entries data["Last"] = meta.lastByName s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data}) case "blob": size := g.Size(rp.Commit, filePath) data["Size"] = size if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { data["File"] = true data["Media"], _, _ = strings.Cut(kind, "/") s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Data: data}) return } var content []byte if size <= maxRender { if content, err = g.Blob(rp.Commit, filePath); err != nil { s.fail(w, r, 404, "No such path") return } } data["File"] = true if size > maxRender { data["Large"] = true } else if isBinary(content) { data["Binary"] = true } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" { dir := "" if i := strings.LastIndex(filePath, "/"); i >= 0 { dir = filePath[:i+1] } data["Markdown"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/"+dir, "/"+repo.Path()+"/tree/"+rp.Ref+"/"+dir) } else if h, ok := highlightLimited(filePath, content); ok { data["Highlighted"] = h } else { data["Content"] = content } s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Math: hasMath(data["Markdown"]), Data: data}) default: s.fail(w, r, 404, "No such path") } } const maxMedia = 64 << 20 var mediaTypes = map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", } func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such ref") return } filePath, ok = cleanPath(filePath) if !ok || filePath == "" { s.fail(w, r, 404, "No such path") return } commit, err := g.Resolve(ref) if err != nil { s.fail(w, r, 404, "No such ref") return } if g.Kind(commit, filePath) != "blob" { s.fail(w, r, 404, "No such path") return } w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) size := g.Size(commit, filePath) if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { data, err := g.Blob(commit, filePath) if err != nil { s.fail(w, r, 500, err.Error()) return } w.Header().Set("Content-Type", kind) w.Header().Set("Cache-Control", cacheScope(repo)+", max-age=300") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox") http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) g.Stream(w, commit, filePath) } func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref string) { ref = strings.Trim(ref, "/") rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "log" filePath, ok := cleanPath(r.URL.Query().Get("path")) if !ok { s.fail(w, r, 404, "No such path") return } query, mode := r.URL.Query().Get("q"), r.URL.Query().Get("mode") var pickaxe []string if query != "" { if mode == "regex" { pickaxe = []string{"-G" + query} } else { mode = "text" pickaxe = []string{"-S" + query} } } data := map[string]any{"Page": rp, "Path": filePath, "Query": query, "Mode": mode} if !rp.Empty { start := rp.Commit if from := r.URL.Query().Get("from"); from != "" { if start, err = g.Resolve(from); err != nil { s.fail(w, r, 404, "No such commit") return } } const per = 50 commits, err := g.Log(start, per+1, filePath, pickaxe...) if err != nil && query != "" { data["Error"] = "That search could not run, check the pattern" } else if err != nil { s.fail(w, r, 500, err.Error()) return } if len(commits) > per { data["Next"] = commits[per].Hash commits = commits[:per] } data["Commits"] = commits } s.render(w, r, "log", page{Title: repo.Path() + " log", Data: data}) } func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash string) { rp, g, err := s.repoPage(r, repo, hash) if err != nil { s.fail(w, r, 404, "No such commit") return } rp.Tab = "log" c, err := g.Commit(rp.Commit) if err != nil { s.fail(w, r, 404, "No such commit") return } files, err := g.Diff(rp.Commit) if err != nil { s.fail(w, r, 500, err.Error()) return } added, removed := 0, 0 for _, f := range files { added += f.Added removed += f.Removed } s.render(w, r, "commit", page{Title: c.Subject, Data: map[string]any{"Page": rp, "Commit": c, "Files": files, "Added": added, "Removed": removed}}) } type matchGroup struct { Path string Matches []Match } func (s *Server) search(w http.ResponseWriter, r *http.Request, repo Repo) { ref := strings.TrimSpace(r.URL.Query().Get("ref")) rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "tree" query := strings.TrimSpace(r.URL.Query().Get("q")) data := map[string]any{"Page": rp, "Query": query} if query != "" && !rp.Empty { if len(query) > 200 { query = query[:200] } matches, truncated, err := g.Grep(rp.Commit, query, 500) if err != nil { s.fail(w, r, 500, err.Error()) return } var groups []matchGroup for _, m := range matches { if len(groups) == 0 || groups[len(groups)-1].Path != m.Path { groups = append(groups, matchGroup{Path: m.Path}) } groups[len(groups)-1].Matches = append(groups[len(groups)-1].Matches, m) } data["Groups"] = groups data["Count"] = len(matches) data["Truncated"] = truncated } s.render(w, r, "search", page{Title: repo.Path() + " search", Data: data}) } func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "refs" var branches, tags []Ref for _, ref := range s.refTable(g).list { if ref.Tag { tags = append(tags, ref) } else { branches = append(branches, ref) } } s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}}) } func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } rp.Tab = "settings" collabs, _ := s.store.Collaborators(repo.ID) labels, _ := s.store.Labels(repo.ID) s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs, "Labels": labels, "Protections": s.store.Protections(repo.ID), "Pinned": s.store.Pins(repo.Owner.ID)[repo.ID] > 0}}) } func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } back := "/" + repo.Path() + "/settings" if id, err := strconv.ParseInt(r.FormValue("remove"), 10, 64); err == nil { s.store.RemoveCollaborator(repo.ID, id) } else if name := strings.TrimSpace(strings.TrimPrefix(r.FormValue("name"), "~")); name != "" { if name == repo.Owner.Name { http.Redirect(w, r, back+"?error=That+is+you", http.StatusSeeOther) return } if err := s.store.AddCollaborator(repo.ID, name); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } } http.Redirect(w, r, back, http.StatusSeeOther) } func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")) if pin := r.FormValue("pinned") != ""; err == nil && pin != (s.store.Pins(repo.Owner.ID)[repo.ID] > 0) { err = s.store.Pin(repo.Owner.ID, repo.ID, pin) } if err != nil { rp.Tab = "settings" w.WriteHeader(400) s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: err.Error(), Data: map[string]any{"Page": rp}}) return } http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) } func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } if r.FormValue("confirm") != repo.Name { http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther) return } if err := s.store.DeleteRepo(repo.ID); err != nil { s.fail(w, r, 500, err.Error()) return } os.RemoveAll(s.repoDir(repo)) http.Redirect(w, r, "/~"+repo.Owner.Name, http.StatusSeeOther) } func ago(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return "just now" case d < 2*time.Minute: return "a minute ago" case d < time.Hour: return fmt.Sprintf("%d minutes ago", int(d.Minutes())) case d < 2*time.Hour: return "an hour ago" case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) case d < 48*time.Hour: return "yesterday" case d < 30*24*time.Hour: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) case d < 365*24*time.Hour: return fmt.Sprintf("%d months ago", int(d.Hours()/24/30)) default: return fmt.Sprintf("%d years ago", int(d.Hours()/24/365)) } } func perm(mode string) string { bits := func(o string) string { n, _ := strconv.ParseInt(o, 8, 16) out := []byte("---------") for i := 0; i < 9; i++ { if n&(1<<(8-i)) != 0 { out[i] = "rwx"[i%3] } } return string(out) } switch { case strings.HasPrefix(mode, "040"): return "drwxr-xr-x" case strings.HasPrefix(mode, "120"): return "lrwxrwxrwx" case strings.HasPrefix(mode, "160"): return "m---------" case len(mode) >= 3: return "-" + bits(mode[len(mode)-3:]) } return mode } func humanSize(n int64) string { switch { case n < 1024: return fmt.Sprintf("%d bytes", n) case n < 1<<20: return fmt.Sprintf("%.1f KiB", float64(n)/1024) default: return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) } } func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, action string) { u := s.require(w, r) if u == nil { return } if !s.store.IsMember(org.ID, u.ID) { s.fail(w, r, 403, "You are not a member of this organization") return } back := "/~" + org.Name + "/settings" if r.Method == "POST" && action == "members" { name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~") target, err := s.store.UserByName(name) switch { case err != nil || s.store.IsOrg(target.ID): back += "?error=No+such+user" case r.FormValue("remove") != "": if len(s.store.Members(org.ID)) == 1 { back += "?error=An+organization+needs+at+least+one+member" } else { s.store.SetMember(org.ID, target.ID, false) } default: s.store.SetMember(org.ID, target.ID, true) } http.Redirect(w, r, back, http.StatusSeeOther) return } if r.Method != "GET" || action != "settings" { http.NotFound(w, r) return } var people []userHit for _, m := range s.store.Members(org.ID) { people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)}) } s.render(w, r, "orgsettings", page{Title: "~" + org.Name + " settings", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Org": org, "Profile": s.store.Profile(org.ID), "Members": people}}) } func (s *Server) redirectMoved(w http.ResponseWriter, r *http.Request, owner, name string, user *User, code int) bool { moved, ok := s.store.Redirect(owner, strings.TrimSuffix(name, ".git")) if !ok || !s.store.CanRead(moved, user) { return false } _, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/~"+owner+"/"), "/") target := "/" + moved.Path() if strings.HasSuffix(name, ".git") { target += ".git" } if rest != "" || strings.HasSuffix(r.URL.Path, "/") { target += "/" + rest } if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, code) return true } const contentPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; media-src 'self' https:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" func secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", contentPolicy) h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Cross-Origin-Opener-Policy", "same-origin") next.ServeHTTP(w, r) }) } func escapePath(p string) string { parts := strings.Split(p, "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func cacheScope(repo Repo) string { if repo.Private { return "private" } return "public" } func pinnedRepos(repos []Repo, pins map[int64]int) []Repo { var out []Repo for _, r := range repos { if pins[r.ID] > 0 { out = append(out, r) } } return out } func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user *User) { member := user != nil && (user.ID == owner.ID || s.store.IsMember(owner.ID, user.ID)) repos, err := s.store.ReposByOwner(owner.ID, member) if err != nil { s.fail(w, r, 500, err.Error()) return } data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Member": member, "Count": s.store.StarredCount(owner.ID), "Org": s.store.IsOrg(owner.ID)} s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data}) } type intro struct { Path, Name, Ref string HTML template.HTML } type introEntry struct { stamp time.Time intro intro } var introCache = struct { sync.Mutex m map[int64]introEntry }{m: map[int64]introEntry{}} func (s *Server) profileReadme(owner User) intro { repo, err := s.store.RepoByPath(owner.Name, owner.Name) if err != nil || repo.Private || repo.Unlisted { return intro{} } g := Git{Dir: s.repoDir(repo)} stamp := repoUpdated(g.Dir) introCache.Lock() e, ok := introCache.m[repo.ID] introCache.Unlock() if ok && e.stamp.Equal(stamp) { return e.intro } var in intro ref := g.Head() if commit, err := g.Resolve(ref); err == nil { if name, content := g.Readme(commit); strings.HasSuffix(strings.ToLower(name), ".md") && len(content) <= 256<<10 { in = intro{Path: repo.Path(), Name: name, Ref: ref, HTML: s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+ref+"/", "/"+repo.Path()+"/tree/"+ref+"/")} } } introCache.Lock() introCache.m[repo.ID] = introEntry{stamp, in} introCache.Unlock() return in } var nameWords = []string{"heron", "lantern", "harbor", "kestrel", "thicket", "anvil", "orchard", "beacon", "lattice", "meadow", "quarry", "sparrow", "tundra", "compass", "furnace", "willow", "ledger", "marrow", "pebble", "spindle", "cinder", "bramble", "atlas", "ferry", "loom", "estuary", "magpie", "bellows", "cairn", "fathom", "gantry", "hearth", "juniper", "keel", "lichen", "mortar", "nettle", "osprey", "plinth", "quill", "rookery", "sextant", "tinder", "umber", "vellum", "wicket", "yarrow", "zephyr", "sorrel", "flint"} func suggestName() string { return nameWords[rand.IntN(len(nameWords))] } func (s *Server) staticCache(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("v") == s.version { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } } type summaryEntry struct { stamp time.Time ref string commit string commits []Commit branches []Ref tags []Ref readmeName string readme template.HTML readmeText string } var summaries = struct { sync.Mutex m map[int64]summaryEntry }{m: map[int64]summaryEntry{}} func (s *Server) summaryData(repo Repo, g Git, rp repoPage) summaryEntry { stamp := repoUpdated(g.Dir) summaries.Lock() e, ok := summaries.m[repo.ID] summaries.Unlock() if ok && e.stamp.Equal(stamp) && e.ref == rp.Ref && e.commit == rp.Commit { return e } e = summaryEntry{stamp: stamp, ref: rp.Ref, commit: rp.Commit} e.commits, _ = g.Log(rp.Commit, 4, "") for _, r := range s.refTable(g).list { if r.Tag { e.tags = append(e.tags, r) } else { e.branches = append(e.branches, r) } } name, content := g.Readme(rp.Commit) e.readmeName = name if strings.HasSuffix(strings.ToLower(name), ".md") { e.readme = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/") } else if name != "" { e.readmeText = string(content) } summaries.Lock() if len(summaries.m) > 1024 { clear(summaries.m) } summaries.m[repo.ID] = e summaries.Unlock() return e } type refSet struct { stamp time.Time list []Ref byName map[string]string } var refCache = struct { sync.Mutex m map[string]refSet }{m: map[string]refSet{}} func (s *Server) refTable(g Git) refSet { stamp := repoUpdated(g.Dir) refCache.Lock() rs, ok := refCache.m[g.Dir] refCache.Unlock() if ok && rs.stamp.Equal(stamp) { return rs } list, err := g.Refs("refs/heads", "refs/tags") rs = refSet{stamp: stamp, list: list, byName: map[string]string{}} for _, r := range list { if _, dup := rs.byName[r.Name]; !dup || !r.Tag { rs.byName[r.Name] = r.Hash } } if err == nil { refCache.Lock() if len(refCache.m) > 1024 { clear(refCache.m) } refCache.m[g.Dir] = rs refCache.Unlock() } return rs } func (rs refSet) branches() []Ref { var out []Ref for _, r := range rs.list { if !r.Tag { out = append(out, r) } } return out } type pushedBranch struct { Ref Ahead int } func (s *Server) recentPushes(repo Repo, g Git, rp repoPage) []pushedBranch { if !rp.Owner || rp.Empty { return nil } head := g.Head() var out []pushedBranch for _, b := range s.refTable(g).branches() { if len(out) == 3 || time.Since(b.Time) > 72*time.Hour { break } if b.Name == head || s.store.HasOpenPull(repo.ID, b.Name) { continue } n, err := g.run(nil, "rev-list", "--count", "--end-of-options", "refs/heads/"+head+"..refs/heads/"+b.Name) if ahead, _ := strconv.Atoi(strings.TrimSpace(string(n))); err == nil && ahead > 0 { out = append(out, pushedBranch{b, ahead}) } } return out } type treeMeta struct { kind string last *Commit entry *Entry entries []Entry lastByName map[string]Commit } var treeCache = struct { sync.Mutex m map[string]treeMeta }{m: map[string]treeMeta{}} func treeInfo(g Git, commit, filePath string) treeMeta { key := commit + "\x00" + filePath treeCache.Lock() m, ok := treeCache.m[key] treeCache.Unlock() if ok { return m } if last, _ := g.Log(commit, 1, filePath); len(last) > 0 { m.last = &last[0] } m.entry = g.Entry(commit, filePath) m.kind = g.Kind(commit, filePath) if m.kind == "tree" || m.kind == "commit" { if entries, err := g.Tree(commit, filePath); err == nil { m.entries = entries if m.entries == nil { m.entries = []Entry{} } names := make([]string, len(entries)) for i, e := range entries { names[i] = e.Name } m.lastByName = g.LastCommits(commit, filePath, names) } } if m.kind == "" || ((m.kind == "tree" || m.kind == "commit") && m.entries == nil) { return m } treeCache.Lock() if len(treeCache.m) > 2048 { clear(treeCache.m) } treeCache.m[key] = m treeCache.Unlock() return m }