package app import ( "bytes" "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "html/template" "mime" "net/http" "net/url" "os" "os/exec" pathpkg "path" "path/filepath" "strconv" "strings" "time" "unicode/utf8" "github.com/yuin/goldmark" "golang.org/x/crypto/bcrypt" ) const ( webAuthCookie = "gitocean_web_token" webCSRFCookie = "gitocean_csrf" maxWebFileBytes = 1024 * 1024 maxWebDiffBytes = 200 * 1024 ) type webPageData struct { Title string User User Authed bool CSRF string Error string Data any Content template.HTML } type webReposData struct { Query string Repos []Repository } type webRepoData struct { Repo Repository Ref string Path string Entries []webTreeEntry CanWrite bool CanFork bool Branches []RefInfo CloneURL string ParentPath string PRs []PullRequest LatestCommit *webCommit CommitCount int } type webBlobData struct { Repo Repository Ref string Path string ParentPath string Content string RenderedMarkdown template.HTML IsMarkdown bool IsImage bool ImageURL string CanWrite bool } type webEditData struct { Repo Repository Ref string Path string Content string CommitMessage string CommitDescription string } type webTreeEntry struct { Name string Path string Type string Size string } type webBranchesData struct { Repo Repository Branches []RefInfo CanWrite bool } type webCommit struct { Hash string Subject string Body string AuthorName string AuthorEmail string Date time.Time Diff string DiffLines []webDiffLine DiffTruncated bool } type webDiffLine struct { Index int Kind string OldLine string NewLine string Text string } type webCommitHistoryData struct { Repo Repository Ref string Branches []RefInfo Commits []webCommit } type webCommitData struct { Repo Repository Ref string Commit webCommit } type webPRListData struct { Repo Repository PRs []PullRequest } type webPRData struct { Repo Repository PR PullRequest Comments []PRComment CanManage bool CanComment bool } type webPRNewData struct { Repo Repository Branches []RefInfo } type webProfileData struct { Profile User Repos []Repository } var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ "urlquery": url.QueryEscape, "shortHash": shortHash, "truncate": truncateString, }).Parse(`{{define "layout"}} {{.Title}} - gitocean
gitocean repos {{if .Authed}} new repo logged in as {{.User.Username}}
{{else}} login register {{end}}
{{if .Error}}

{{.Error}}

{{end}} {{.Content}} {{end}} {{define "Repos"}}

Repositories

{{if .Data.Repos}} {{range .Data.Repos}} {{end}}
RepositoryVisibilityDescription
{{.Owner}}/{{.Name}}{{.Visibility}}{{.Description}}
{{else}}

No repositories found.

{{end}} {{end}} {{define "Login"}}

Login

{{end}} {{define "Register"}}

Register

{{end}} {{define "New Repository"}}

New repository

{{end}} {{define "Repository"}} {{$d := .Data}}

{{$d.Repo.Owner}}/{{$d.Repo.Name}}

{{$d.Repo.Visibility}} repository{{if $d.Repo.Archived}}; archived{{end}}

{{if $d.Repo.Description}}

{{$d.Repo.Description}}

{{end}}

Clone: {{$d.CloneURL}}

{{if $d.CanFork}}
{{end}}

branches | pull requests {{if .Authed}}| new pull request{{end}}

{{if $d.LatestCommit}}

Latest commit on {{$d.Ref}}: {{shortHash $d.LatestCommit.Hash}} {{truncate $d.LatestCommit.Subject 72}}

{{end}}

{{$d.CommitCount}} commits on {{$d.Ref}}

{{if $d.CanWrite}}

create/edit file

{{end}}

Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}

{{if $d.ParentPath}}

..

{{end}} {{if $d.Entries}} {{range $d.Entries}} {{end}}
NameTypeSize
{{if eq .Type "tree"}}{{.Name}}/{{else}}{{.Name}}{{end}} {{.Type}}{{.Size}} {{if and $d.CanWrite (ne .Type "tree")}}edit{{end}}
{{else}}

No files yet.

{{end}} {{end}} {{define "Branches"}} {{$d := .Data}}

Branches for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

repo

{{if $d.Branches}} {{range $d.Branches}} {{end}}
BranchCommit
{{.Name}}{{if eq .Name $d.Repo.DefaultBranch}} (default){{end}} {{.Commit}} {{if and $d.CanWrite (ne .Name $d.Repo.DefaultBranch)}}
{{end}}
{{else}}

No branches yet.

{{end}} {{if $d.CanWrite}}

New branch

{{end}} {{end}} {{define "Commit History"}} {{$d := .Data}}

Commits for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

repo

{{if $d.Commits}} {{range $d.Commits}} {{end}}
CommitAuthorDate
{{shortHash .Hash}} {{.Subject}} {{.AuthorName}} {{.Date}}
{{else}}

No commits.

{{end}} {{end}} {{define "Commit"}} {{$d := .Data}}

Commit {{shortHash $d.Commit.Hash}}

commit history | tree

{{$d.Commit.Subject}}

{{$d.Commit.Hash}}

{{$d.Commit.AuthorName}} <{{$d.Commit.AuthorEmail}}> authored {{$d.Commit.Date}}

{{if $d.Commit.Body}}
{{$d.Commit.Body}}
{{end}}

Diff

{{if $d.Commit.DiffTruncated}}Diff truncated. Showing the first diff chunk only.{{else}}Review changes with line numbers.{{end}}
{{if $d.Commit.DiffLines}}
{{range $d.Commit.DiffLines}}
{{.OldLine}} {{.NewLine}}
{{.Text}}
{{end}}
{{else}}

No diff for this commit.

{{end}} {{end}} {{define "File"}} {{$d := .Data}}

{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}

back to tree{{if $d.CanWrite}} | edit{{end}}

{{if $d.IsImage}}

{{$d.Path}}

{{else if $d.IsMarkdown}}
{{$d.RenderedMarkdown}}
{{else}}
{{$d.Content}}
{{end}} {{end}} {{define "Edit File"}} {{$d := .Data}}

Edit {{$d.Repo.Owner}}/{{$d.Repo.Name}}

Commit

{{end}} {{define "Pull Requests"}} {{$d := .Data}}

Pull requests for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

repo {{if .Authed}}| new pull request{{end}}

{{if $d.PRs}} {{range $d.PRs}}{{end}}
#TitleStatusBranches
#{{.Number}}{{.Title}}{{.Status}}{{.SourceOwner}}/{{.SourceRepo}}:{{.SourceBranch}} → {{.TargetBranch}}
{{else}}

No pull requests.

{{end}} {{end}} {{define "New Pull Request"}} {{$d := .Data}}

New pull request for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

{{end}} {{define "Pull Request"}} {{$d := .Data}}

#{{$d.PR.Number}} {{$d.PR.Title}}

pull requests | {{$d.PR.Status}} | {{$d.PR.SourceOwner}}/{{$d.PR.SourceRepo}}:{{$d.PR.SourceBranch}} → {{$d.PR.TargetBranch}}

{{if $d.PR.Description}}

{{$d.PR.Description}}

{{end}} {{if $d.CanManage}}{{if eq $d.PR.Status "open"}}
{{end}}{{end}}

Comments

{{range $d.Comments}}
{{.Author}}
{{.Body}}
{{else}}

No comments.

{{end}} {{if $d.CanComment}}

{{end}} {{end}} {{define "Profile"}} {{$d := .Data}}

{{$d.Profile.Username}}

{{$d.Profile.Email}}

Public repositories

{{if $d.Repos}} {{range $d.Repos}}{{end}}
RepositoryDescription
{{.Owner}}/{{.Name}}{{.Description}}
{{else}}

No public repositories.

{{end}} {{end}}`)) func (s *Server) handleWeb(w http.ResponseWriter, r *http.Request) { path := strings.Trim(r.URL.Path, "/") switch { case r.URL.Path == "/" && r.Method == http.MethodGet: http.Redirect(w, r, "/repos", http.StatusSeeOther) case r.URL.Path == "/repos" && r.Method == http.MethodGet: s.webRepos(w, r) case r.URL.Path == "/login": if r.Method == http.MethodGet { s.renderWeb(w, r, "Login", nil, "") return } if r.Method == http.MethodPost { s.webLoginPost(w, r) return } webError(w, r, http.StatusMethodNotAllowed, "method not allowed") case r.URL.Path == "/register": if r.Method == http.MethodGet { s.renderWeb(w, r, "Register", nil, "") return } if r.Method == http.MethodPost { s.webRegisterPost(w, r) return } webError(w, r, http.StatusMethodNotAllowed, "method not allowed") case r.URL.Path == "/logout" && r.Method == http.MethodPost: s.webLogoutPost(w, r) case r.URL.Path == "/repos/new": if r.Method == http.MethodGet { s.webRepoNew(w, r, "") return } if r.Method == http.MethodPost { s.webRepoNewPost(w, r) return } webError(w, r, http.StatusMethodNotAllowed, "method not allowed") case path != "": s.webRepoRoute(w, r, strings.Split(path, "/")) default: webError(w, r, http.StatusNotFound, "not found") } } func (s *Server) renderWeb(w http.ResponseWriter, r *http.Request, title string, data any, errMsg string) { user, authed := s.optionalWebUser(r) pd := webPageData{Title: title, User: user, Authed: authed, CSRF: csrfTokenFor(w, r), Error: errMsg, Data: data} var content bytes.Buffer if err := webTemplates.ExecuteTemplate(&content, title, pd); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } pd.Content = template.HTML(content.String()) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := webTemplates.ExecuteTemplate(w, "layout", pd); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func webError(w http.ResponseWriter, r *http.Request, status int, msg string) { w.WriteHeader(status) _, _ = fmt.Fprintf(w, "Error

Error

%s

repos

", template.HTMLEscapeString(msg)) } func (s *Server) webRepos(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) like := "%" + q + "%" user, authed := s.optionalWebUser(r) var rows *sql.Rows var err error if authed { rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE (r.visibility = 'public' OR r.owner_user_id = ? OR EXISTS (SELECT 1 FROM repository_collaborators c WHERE c.repository_id = r.id AND c.user_id = ?)) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, user.ID, user.ID, q, like, like) } else { rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE r.visibility = 'public' AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, q, like, like) } if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } defer rows.Close() repos, err := scanRepos(rows) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderWeb(w, r, "Repos", webReposData{Query: q, Repos: repos}, "") } func (s *Server) webLoginPost(w http.ResponseWriter, r *http.Request) { if !validWebCSRF(r) { s.renderWeb(w, r, "Login", nil, "invalid form token") return } login := strings.ToLower(strings.TrimSpace(r.FormValue("login"))) password := r.FormValue("password") var user User var hash string err := s.db.QueryRow(`SELECT id, email, username, is_admin, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin, &hash) if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { s.renderWeb(w, r, "Login", nil, "invalid credentials") return } token, _, err := s.createToken(user.ID) if err != nil { s.renderWeb(w, r, "Login", nil, "could not create session") return } setWebAuthCookie(w, r, token) http.Redirect(w, r, "/repos", http.StatusSeeOther) } func (s *Server) webRegisterPost(w http.ResponseWriter, r *http.Request) { if !validWebCSRF(r) { s.renderWeb(w, r, "Register", nil, "invalid form token") return } email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) username := strings.ToLower(strings.TrimSpace(r.FormValue("username"))) password := r.FormValue("password") if !strings.Contains(email, "@") || len(email) > 255 { s.renderWeb(w, r, "Register", nil, "invalid email") return } if !usernameRE.MatchString(username) || isReservedName(username) { s.renderWeb(w, r, "Register", nil, "invalid or reserved username") return } if len(password) < 8 { s.renderWeb(w, r, "Register", nil, "password must be at least 8 characters") return } var userCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) isAdmin := userCount == 0 if err := createUserDirect(s.db, email, username, password, isAdmin); err != nil { s.renderWeb(w, r, "Register", nil, "email or username already exists") return } var userID int64 if err := s.db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&userID); err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } token, _, err := s.createToken(userID) if err == nil { setWebAuthCookie(w, r, token) } http.Redirect(w, r, "/repos", http.StatusSeeOther) } func (s *Server) webLogoutPost(w http.ResponseWriter, r *http.Request) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } if c, err := r.Cookie(webAuthCookie); err == nil && c.Value != "" { _, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(c.Value)) } clearCookie(w, webAuthCookie) http.Redirect(w, r, "/repos", http.StatusSeeOther) } func (s *Server) webRepoNew(w http.ResponseWriter, r *http.Request, errMsg string) { if _, ok := s.optionalWebUser(r); !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.renderWeb(w, r, "New Repository", nil, errMsg) } func (s *Server) webRepoNewPost(w http.ResponseWriter, r *http.Request) { if !validWebCSRF(r) { s.webRepoNew(w, r, "invalid form token") return } user, ok := s.optionalWebUser(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) visibility := strings.ToLower(strings.TrimSpace(r.FormValue("visibility"))) description := strings.TrimSpace(r.FormValue("description")) if !repoNameRE.MatchString(name) || isReservedName(name) { s.webRepoNew(w, r, "invalid repository name") return } if visibility != "public" && visibility != "private" { s.webRepoNew(w, r, "visibility must be public or private") return } res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch) VALUES (?, ?, ?, ?, 'main')`, user.ID, name, visibility, description) if err != nil { s.webRepoNew(w, r, "repository already exists") return } repoID, _ := res.LastInsertId() repoPath := s.repoPath(user.Username, name) if err := os.MkdirAll(filepath.Dir(repoPath), 0755); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) s.webRepoNew(w, r, err.Error()) return } if err := gitInitBare(repoPath); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) s.webRepoNew(w, r, err.Error()) return } http.Redirect(w, r, "/"+user.Username+"/"+name, http.StatusSeeOther) } func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []string) { if len(parts) == 1 { if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webProfile(w, r, strings.ToLower(parts[0])) return } if len(parts) < 2 { webError(w, r, http.StatusNotFound, "not found") return } owner, name := strings.ToLower(parts[0]), strings.ToLower(parts[1]) action := "repo" if len(parts) >= 3 { action = parts[2] } switch action { case "repo": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoTree(w, r, owner, name) case "tree": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoTree(w, r, owner, name) case "blob": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoBlob(w, r, owner, name) case "raw": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoRaw(w, r, owner, name) case "commits": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webCommitHistory(w, r, owner, name) case "commit": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webCommitView(w, r, owner, name) case "edit": if r.Method == http.MethodGet { s.webRepoEdit(w, r, owner, name, "") return } if r.Method == http.MethodPost { s.webRepoEditPost(w, r, owner, name) return } webError(w, r, http.StatusMethodNotAllowed, "method not allowed") case "branches": if len(parts) == 3 && r.Method == http.MethodGet { s.webBranches(w, r, owner, name, "") return } if len(parts) == 3 && r.Method == http.MethodPost { s.webBranchCreatePost(w, r, owner, name) return } if len(parts) == 4 && parts[3] == "delete" && r.Method == http.MethodPost { s.webBranchDeletePost(w, r, owner, name) return } webError(w, r, http.StatusNotFound, "not found") case "pulls": s.webPullsRoute(w, r, owner, name, parts[3:]) case "fork": if r.Method != http.MethodPost { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoForkPost(w, r, owner, name) default: webError(w, r, http.StatusNotFound, "not found") } } func (s *Server) webRepoContext(w http.ResponseWriter, r *http.Request, owner, name string) (Repository, User, bool, bool) { repo, err := s.loadRepo(owner, name) if err != nil { webError(w, r, http.StatusNotFound, "repository not found") return Repository{}, User{}, false, false } user, authed := s.optionalWebUser(r) if !s.canReadRepo(repo, user, authed) { webError(w, r, http.StatusNotFound, "repository not found") return Repository{}, User{}, false, false } return repo, user, authed, true } func (s *Server) webRepoTree(w http.ResponseWriter, r *http.Request, owner, name string) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), true) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } entries, err := gitListTree(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") var latest *webCommit for _, b := range branches { if b.Name == ref && b.Commit != "" { latest = &webCommit{Hash: b.Commit, Subject: b.Message, Date: b.Date} break } } commitCount, _ := gitCommitCount(s.repoPath(repo.Owner, repo.Name), ref) data := webRepoData{Repo: repo, Ref: ref, Path: p, Entries: entries, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived, CanFork: authed && repo.Visibility == "public" && user.ID != repo.OwnerUserID, Branches: branches, CloneURL: s.publicURL + "/" + repo.Owner + "/" + repo.Name + ".git", ParentPath: parentRepoPath(p), LatestCommit: latest, CommitCount: commitCount} s.renderWeb(w, r, "Repository", data, "") } func (s *Server) webBranches(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } branches, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderWeb(w, r, "Branches", webBranchesData{Repo: repo, Branches: branches, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived}, errMsg) } func (s *Server) webBranchCreatePost(w http.ResponseWriter, r *http.Request, owner, name string) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } if !authed || !s.canWriteRepo(repo, user) || repo.Archived { webError(w, r, http.StatusForbidden, "write access required") return } branch := strings.TrimSpace(r.FormValue("name")) from := strings.TrimSpace(r.FormValue("from")) if !branchRE.MatchString(branch) { s.webBranches(w, r, owner, name, "invalid branch name") return } if gitBranchExists(s.repoPath(repo.Owner, repo.Name), branch) { s.webBranches(w, r, owner, name, "branch already exists") return } if from == "" { if err := createEmptyGitBranch(s.repoPath(repo.Owner, repo.Name), branch, user); err != nil { s.webBranches(w, r, owner, name, err.Error()) return } } else { if !branchRE.MatchString(from) || !gitBranchExists(s.repoPath(repo.Owner, repo.Name), from) { s.webBranches(w, r, owner, name, "source branch does not exist") return } if err := createGitBranchFrom(s.repoPath(repo.Owner, repo.Name), branch, from); err != nil { s.webBranches(w, r, owner, name, err.Error()) return } } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/branches", http.StatusSeeOther) } func (s *Server) webBranchDeletePost(w http.ResponseWriter, r *http.Request, owner, name string) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } if !authed || !s.canWriteRepo(repo, user) || repo.Archived { webError(w, r, http.StatusForbidden, "write access required") return } branch := strings.TrimSpace(r.FormValue("name")) if !branchRE.MatchString(branch) { s.webBranches(w, r, owner, name, "invalid branch name") return } if branch == repo.DefaultBranch { s.webBranches(w, r, owner, name, "cannot delete the default branch") return } if !gitBranchExists(s.repoPath(repo.Owner, repo.Name), branch) { s.webBranches(w, r, owner, name, "branch does not exist") return } if err := deleteGitBranch(s.repoPath(repo.Owner, repo.Name), branch); err != nil { s.webBranches(w, r, owner, name, err.Error()) return } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/branches", http.StatusSeeOther) } func (s *Server) webCommitHistory(w http.ResponseWriter, r *http.Request, owner, name string) { repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") commits, err := gitCommitHistory(s.repoPath(repo.Owner, repo.Name), ref, 100) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } s.renderWeb(w, r, "Commit History", webCommitHistoryData{Repo: repo, Ref: ref, Branches: branches, Commits: commits}, "") } func (s *Server) webCommitView(w http.ResponseWriter, r *http.Request, owner, name string) { repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { webError(w, r, http.StatusBadRequest, "commit id is required") return } commit, err := gitCommitDetail(s.repoPath(repo.Owner, repo.Name), ref, id) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } s.renderWeb(w, r, "Commit", webCommitData{Repo: repo, Ref: ref, Commit: commit}, "") } func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name string) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), false) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } data := webBlobData{Repo: repo, Ref: ref, Path: p, ParentPath: parentRepoPath(p), CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived} if isWebImagePath(p) { if _, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p); err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } data.IsImage = true data.ImageURL = "/" + repo.Owner + "/" + repo.Name + "/raw?ref=" + url.QueryEscape(ref) + "&path=" + url.QueryEscape(p) } else { content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } data.Content = content if isMarkdownPath(p) { md, err := renderMarkdown(content) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } data.IsMarkdown = true data.RenderedMarkdown = md } } s.renderWeb(w, r, "File", data, "") } func (s *Server) webRepoRaw(w http.ResponseWriter, r *http.Request, owner, name string) { repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), false) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } if !isWebImagePath(p) { webError(w, r, http.StatusBadRequest, "raw web rendering is only available for images") return } b, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(p))) if ct == "" { ct = http.DetectContentType(b) } w.Header().Set("Content-Type", ct) _, _ = w.Write(b) } func (s *Server) webRepoEdit(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !s.canWriteRepo(repo, user) || repo.Archived { webError(w, r, http.StatusForbidden, "write access required") return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), true) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } content := "" if p != "" { if c, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p); err == nil { content = c } } s.renderWeb(w, r, "Edit File", webEditData{Repo: repo, Ref: ref, Path: p, Content: content, CommitMessage: defaultEditCommitMessage(p)}, errMsg) } func (s *Server) webRepoEditPost(w http.ResponseWriter, r *http.Request, owner, name string) { if !validWebCSRF(r) { s.webRepoEdit(w, r, owner, name, "invalid form token") return } repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } if !authed || !s.canWriteRepo(repo, user) || repo.Archived { webError(w, r, http.StatusForbidden, "write access required") return } ref := strings.TrimSpace(r.FormValue("ref")) if ref == "" { ref = repo.DefaultBranch } if !branchRE.MatchString(ref) { s.webRepoEdit(w, r, owner, name, "invalid branch") return } p, err := cleanRepoFilePath(r.FormValue("path"), false) if err != nil { s.webRepoEdit(w, r, owner, name, err.Error()) return } message, description, err := editCommitFields(p, r.FormValue("commit_message"), r.FormValue("commit_description")) if err != nil { s.webRepoEdit(w, r, owner, name, err.Error()) return } if err := s.commitEditedFile(repo, ref, p, r.FormValue("content"), message, description, user); err != nil { s.webRepoEdit(w, r, owner, name, err.Error()) return } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/blob?ref="+url.QueryEscape(ref)+"&path="+url.QueryEscape(p), http.StatusSeeOther) } func (s *Server) webRepoForkPost(w http.ResponseWriter, r *http.Request, owner, name string) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } user, authed := s.optionalWebUser(r) if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } src, err := s.loadRepo(owner, name) if err != nil || src.Visibility != "public" { webError(w, r, http.StatusNotFound, "repository not found") return } newName := src.Name res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch, forked_from_repository_id) VALUES (?, ?, 'public', ?, ?, ?)`, user.ID, newName, src.Description, src.DefaultBranch, src.ID) if err != nil { webError(w, r, http.StatusConflict, "repository already exists") return } newID, _ := res.LastInsertId() dstPath := s.repoPath(user.Username, newName) if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID) webError(w, r, http.StatusInternalServerError, err.Error()) return } cmd := exec.Command("git", "clone", "--bare", s.repoPath(src.Owner, src.Name), dstPath) if out, err := cmd.CombinedOutput(); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID) _ = os.RemoveAll(dstPath) webError(w, r, http.StatusInternalServerError, strings.TrimSpace(string(out))) return } http.Redirect(w, r, "/"+user.Username+"/"+newName, http.StatusSeeOther) } func (s *Server) webPullsRoute(w http.ResponseWriter, r *http.Request, owner, name string, parts []string) { if len(parts) == 0 { if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webPRList(w, r, owner, name) return } if len(parts) == 1 && parts[0] == "new" { if r.Method == http.MethodGet { s.webPRNew(w, r, owner, name, "") return } if r.Method == http.MethodPost { s.webPRCreatePost(w, r, owner, name) return } webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } n, err := strconv.Atoi(parts[0]) if err != nil { webError(w, r, http.StatusBadRequest, "invalid pull request number") return } if len(parts) == 1 && r.Method == http.MethodGet { s.webPRView(w, r, owner, name, n) return } if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "comments" { s.webPRCommentPost(w, r, owner, name, n) return } if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "close" { s.webPRClosePost(w, r, owner, name, n) return } if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "merge" { s.webPRMergePost(w, r, owner, name, n) return } webError(w, r, http.StatusNotFound, "not found") } func (s *Server) webPRList(w http.ResponseWriter, r *http.Request, owner, name string) { repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } prs, err := s.listPRs(repo.ID) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderWeb(w, r, "Pull Requests", webPRListData{Repo: repo, PRs: prs}, "") } func (s *Server) webPRNew(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { if _, ok := s.optionalWebUser(r); !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") s.renderWeb(w, r, "New Pull Request", webPRNewData{Repo: repo, Branches: branches}, errMsg) } func (s *Server) webPRCreatePost(w http.ResponseWriter, r *http.Request, owner, name string) { if !validWebCSRF(r) { s.webPRNew(w, r, owner, name, "invalid form token") return } user, authed := s.optionalWebUser(r) if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } target, err := s.loadRepo(owner, name) if err != nil || !s.canReadRepo(target, user, true) { webError(w, r, http.StatusNotFound, "target repository not found") return } sourceOwner := strings.ToLower(strings.TrimSpace(r.FormValue("source_owner"))) sourceRepo := strings.ToLower(strings.TrimSpace(r.FormValue("source_repo"))) sourceBranch := strings.TrimSpace(r.FormValue("source_branch")) targetBranch := strings.TrimSpace(r.FormValue("target_branch")) title := strings.TrimSpace(r.FormValue("title")) description := strings.TrimSpace(r.FormValue("description")) if sourceOwner == "" { sourceOwner = target.Owner } if sourceRepo == "" { sourceRepo = target.Name } if title == "" || !branchRE.MatchString(sourceBranch) || !branchRE.MatchString(targetBranch) { s.webPRNew(w, r, owner, name, "title and valid source/target branches are required") return } source, err := s.loadRepo(sourceOwner, sourceRepo) if err != nil { s.webPRNew(w, r, owner, name, "source repository not found") return } if source.ID == target.ID { if target.OwnerUserID != user.ID { webError(w, r, http.StatusForbidden, "same-repository PRs require repository ownership") return } } else { if source.OwnerUserID != user.ID { webError(w, r, http.StatusForbidden, "source repository must be owned by you") return } if target.Visibility != "public" && target.OwnerUserID != user.ID { webError(w, r, http.StatusForbidden, "target repository is private") return } } if !gitBranchExists(s.repoPath(source.Owner, source.Name), sourceBranch) || !gitBranchExists(s.repoPath(target.Owner, target.Name), targetBranch) { s.webPRNew(w, r, owner, name, "source and target branches must exist") return } var number int _ = s.db.QueryRow(`SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE target_repository_id = ?`, target.ID).Scan(&number) res, err := s.db.Exec(`INSERT INTO pull_requests (target_repository_id, number, author_user_id, source_repository_id, source_branch, target_branch, title, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, target.ID, number, user.ID, source.ID, sourceBranch, targetBranch, title, description) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } if number == 0 { id, _ := res.LastInsertId() _ = id } http.Redirect(w, r, "/"+target.Owner+"/"+target.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther) } func (s *Server) webPRView(w http.ResponseWriter, r *http.Request, owner, name string, number int) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } pr, err := s.loadPR(repo.ID, number) if err != nil { webError(w, r, http.StatusNotFound, "pull request not found") return } comments, err := s.listPRComments(pr.ID) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderWeb(w, r, "Pull Request", webPRData{Repo: repo, PR: pr, Comments: comments, CanManage: authed && user.ID == repo.OwnerUserID, CanComment: authed}, "") } func (s *Server) webPRCommentPost(w http.ResponseWriter, r *http.Request, owner, name string, number int) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } user, authed := s.optionalWebUser(r) if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } repo, err := s.loadRepo(owner, name) if err != nil || !s.canReadRepo(repo, user, true) { webError(w, r, http.StatusNotFound, "repository not found") return } pr, err := s.loadPR(repo.ID, number) if err != nil { webError(w, r, http.StatusNotFound, "pull request not found") return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { webError(w, r, http.StatusBadRequest, "comment body is required") return } _, err = s.db.Exec(`INSERT INTO pull_request_comments (pull_request_id, author_user_id, body) VALUES (?, ?, ?)`, pr.ID, user.ID, body) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther) } func (s *Server) webPRClosePost(w http.ResponseWriter, r *http.Request, owner, name string, number int) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } user, authed := s.optionalWebUser(r) if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } repo, err := s.loadRepo(owner, name) if err != nil || repo.OwnerUserID != user.ID { webError(w, r, http.StatusForbidden, "only target owner can close pull requests") return } res, err := s.db.Exec(`UPDATE pull_requests SET status = 'closed', closed_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ? AND status = 'open'`, repo.ID, number) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } affected, _ := res.RowsAffected() if affected == 0 { webError(w, r, http.StatusConflict, "pull request is not open") return } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther) } func (s *Server) webPRMergePost(w http.ResponseWriter, r *http.Request, owner, name string, number int) { if !validWebCSRF(r) { webError(w, r, http.StatusBadRequest, "invalid form token") return } user, authed := s.optionalWebUser(r) if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } repo, err := s.loadRepo(owner, name) if err != nil || repo.OwnerUserID != user.ID { webError(w, r, http.StatusForbidden, "only target owner can merge pull requests") return } pr, err := s.loadPR(repo.ID, number) if err != nil || pr.Status != "open" { webError(w, r, http.StatusConflict, "pull request is not open") return } if err := s.mergePR(pr); err != nil { webError(w, r, http.StatusConflict, err.Error()) return } _, err = s.db.Exec(`UPDATE pull_requests SET status = 'merged', merged_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ?`, repo.ID, number) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther) } func (s *Server) listPRs(repoID int64) ([]PullRequest, error) { rows, err := s.db.Query(prSelectSQL()+` WHERE pr.target_repository_id = ? ORDER BY pr.number DESC`, repoID) if err != nil { return nil, err } defer rows.Close() return scanPRs(rows) } func (s *Server) listPRComments(prID int64) ([]PRComment, error) { rows, err := s.db.Query(`SELECT c.id, u.username, c.body, c.created_at, c.updated_at FROM pull_request_comments c JOIN users u ON u.id = c.author_user_id WHERE c.pull_request_id = ? ORDER BY c.created_at`, prID) if err != nil { return nil, err } defer rows.Close() var out []PRComment for rows.Next() { var c PRComment if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } func (s *Server) webProfile(w http.ResponseWriter, r *http.Request, username string) { if s.db == nil { webError(w, r, http.StatusNotFound, "user not found") return } var profile User if err := s.db.QueryRow(`SELECT id, email, username, is_admin FROM users WHERE username = ?`, username).Scan(&profile.ID, &profile.Email, &profile.Username, &profile.IsAdmin); err != nil { webError(w, r, http.StatusNotFound, "user not found") return } rows, err := s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE u.username = ? AND r.visibility = 'public' ORDER BY r.updated_at DESC LIMIT 100`, username) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } defer rows.Close() repos, err := scanRepos(rows) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } s.renderWeb(w, r, "Profile", webProfileData{Profile: profile, Repos: repos}, "") } func (s *Server) optionalWebUser(r *http.Request) (User, bool) { c, err := r.Cookie(webAuthCookie) if err != nil || c.Value == "" { return User{}, false } user, err := s.userFromToken(c.Value, "") return user, err == nil } func setWebAuthCookie(w http.ResponseWriter, r *http.Request, token string) { http.SetCookie(w, &http.Cookie{Name: webAuthCookie, Value: token, Path: "/", Expires: time.Now().Add(tokenTTL), MaxAge: int(tokenTTL.Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil}) } func clearCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } func csrfTokenFor(w http.ResponseWriter, r *http.Request) string { if c, err := r.Cookie(webCSRFCookie); err == nil && c.Value != "" { return c.Value } raw := make([]byte, 32) _, _ = rand.Read(raw) token := base64.RawURLEncoding.EncodeToString(raw) http.SetCookie(w, &http.Cookie{Name: webCSRFCookie, Value: token, Path: "/", Expires: time.Now().Add(tokenTTL), MaxAge: int(tokenTTL.Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil}) return token } func validWebCSRF(r *http.Request) bool { if err := r.ParseForm(); err != nil { return false } c, err := r.Cookie(webCSRFCookie) return err == nil && c.Value != "" && r.FormValue("_csrf") == c.Value } func webRef(r *http.Request, repo Repository) string { ref := strings.TrimSpace(r.URL.Query().Get("ref")) if ref == "" { ref = repo.DefaultBranch } if ref == "" { ref = "main" } return ref } func shortHash(hash string) string { if len(hash) > 10 { return hash[:10] } return hash } func truncateString(s string, n int) string { if n <= 0 || len([]rune(s)) <= n { return s } r := []rune(s) return string(r[:n-1]) + "…" } func cleanRepoFilePath(p string, allowEmpty bool) (string, error) { p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/")) if p == "" { if allowEmpty { return "", nil } return "", errors.New("path is required") } if strings.HasPrefix(p, "/") { return "", errors.New("invalid path") } for _, part := range strings.Split(p, "/") { if part == ".." || part == "." || strings.ContainsAny(part, "\x00\r\n") { return "", errors.New("invalid path") } } clean := pathpkg.Clean(p) if clean == "." { clean = "" } if clean == "" && !allowEmpty { return "", errors.New("path is required") } return clean, nil } func parentRepoPath(p string) string { if p == "" { return "" } parent := pathpkg.Dir(p) if parent == "." { return "" } return parent } func gitListTree(repoPath, ref, p string) ([]webTreeEntry, error) { if !branchRE.MatchString(ref) { return nil, errors.New("invalid branch") } if !gitBranchExists(repoPath, ref) { return nil, nil } spec := ref if p != "" { spec += ":" + p } out, err := exec.Command("git", "--git-dir", repoPath, "ls-tree", "-z", "-l", spec).CombinedOutput() if err != nil { return nil, fmt.Errorf("git ls-tree failed: %s", strings.TrimSpace(string(out))) } var entries []webTreeEntry for _, rec := range strings.Split(string(out), "\x00") { if rec == "" { continue } meta, name, ok := strings.Cut(rec, "\t") if !ok { continue } fields := strings.Fields(meta) if len(fields) < 4 { continue } entryPath := name if p != "" { entryPath = p + "/" + name } size := "" if fields[1] == "blob" && fields[3] != "-" { size = fields[3] } entries = append(entries, webTreeEntry{Name: name, Path: entryPath, Type: fields[1], Size: size}) } return entries, nil } func gitCommitCount(repoPath, ref string) (int, error) { if !branchRE.MatchString(ref) { return 0, errors.New("invalid branch") } if !gitBranchExists(repoPath, ref) { return 0, nil } out, err := exec.Command("git", "--git-dir", repoPath, "rev-list", "--count", "refs/heads/"+ref).CombinedOutput() if err != nil { return 0, fmt.Errorf("git rev-list failed: %s", strings.TrimSpace(string(out))) } count, _ := strconv.Atoi(strings.TrimSpace(string(out))) return count, nil } func gitCommitHistory(repoPath, ref string, limit int) ([]webCommit, error) { if !branchRE.MatchString(ref) { return nil, errors.New("invalid branch") } if !gitBranchExists(repoPath, ref) { return nil, errors.New("branch not found") } if limit <= 0 || limit > 500 { limit = 100 } format := "%H%x00%an%x00%ae%x00%ct%x00%s%x00%b%x1e" out, err := exec.Command("git", "--git-dir", repoPath, "log", "-n", strconv.Itoa(limit), "--format="+format, "refs/heads/"+ref).CombinedOutput() if err != nil { return nil, fmt.Errorf("git log failed: %s", strings.TrimSpace(string(out))) } return parseGitCommits(string(out)), nil } func gitCommitDetail(repoPath, ref, id string) (webCommit, error) { full, err := resolveCommitInBranch(repoPath, ref, id) if err != nil { return webCommit{}, err } format := "%H%x00%an%x00%ae%x00%ct%x00%s%x00%b%x1e" out, err := exec.Command("git", "--git-dir", repoPath, "show", "-s", "--format="+format, full).CombinedOutput() if err != nil { return webCommit{}, fmt.Errorf("git show failed: %s", strings.TrimSpace(string(out))) } commits := parseGitCommits(string(out)) if len(commits) == 0 { return webCommit{}, errors.New("commit not found") } commit := commits[0] diff, truncated, err := gitCommitDiff(repoPath, full) if err != nil { return webCommit{}, err } commit.Diff = diff commit.DiffLines = parseDiffLines(diff) commit.DiffTruncated = truncated return commit, nil } func parseGitCommits(out string) []webCommit { var commits []webCommit for _, rec := range strings.Split(out, "\x1e") { rec = strings.Trim(rec, "\n") if rec == "" { continue } parts := strings.SplitN(rec, "\x00", 6) if len(parts) < 6 { continue } unix, _ := strconv.ParseInt(strings.TrimSpace(parts[3]), 10, 64) commits = append(commits, webCommit{Hash: parts[0], AuthorName: parts[1], AuthorEmail: parts[2], Date: time.Unix(unix, 0).UTC(), Subject: parts[4], Body: strings.TrimSpace(parts[5])}) } return commits } func resolveCommitInBranch(repoPath, ref, id string) (string, error) { if !branchRE.MatchString(ref) { return "", errors.New("invalid branch") } if !gitBranchExists(repoPath, ref) { return "", errors.New("branch not found") } if !validCommitID(id) { return "", errors.New("invalid commit id") } out, err := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", id+"^{commit}").CombinedOutput() if err != nil { return "", fmt.Errorf("commit not found") } full := strings.TrimSpace(string(out)) if err := exec.Command("git", "--git-dir", repoPath, "merge-base", "--is-ancestor", full, "refs/heads/"+ref).Run(); err != nil { return "", errors.New("commit is not on branch") } return full, nil } func validCommitID(id string) bool { if len(id) < 4 || len(id) > 64 { return false } for _, r := range id { if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { continue } return false } return true } func gitCommitDiff(repoPath, full string) (string, bool, error) { out, err := exec.Command("git", "--git-dir", repoPath, "show", "--format=", "--patch", "--find-renames", "--no-ext-diff", "--unified=3", "--no-color", full).CombinedOutput() if err != nil { return "", false, fmt.Errorf("git show diff failed: %s", strings.TrimSpace(string(out))) } truncated := false if len(out) > maxWebDiffBytes { out = out[:maxWebDiffBytes] for !utf8.Valid(out) && len(out) > 0 { out = out[:len(out)-1] } truncated = true } return string(out), truncated, nil } func parseDiffLines(diff string) []webDiffLine { if diff == "" { return nil } lines := strings.Split(diff, "\n") if len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } oldLine, newLine := 0, 0 out := make([]webDiffLine, 0, len(lines)) for i, line := range lines { entry := webDiffLine{Index: i + 1, Kind: "context", Text: line} switch { case strings.HasPrefix(line, "diff --git"): oldLine, newLine = 0, 0 entry.Kind = "file" case strings.HasPrefix(line, "@@"): oldLine, newLine = parseDiffHunkHeader(line) entry.Kind = "hunk" case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"): entry.Kind = "add" entry.NewLine = strconv.Itoa(newLine) newLine++ case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"): entry.Kind = "del" entry.OldLine = strconv.Itoa(oldLine) oldLine++ case strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "\\"): entry.Kind = "meta" case oldLine > 0 || newLine > 0: entry.Kind = "context" entry.OldLine = strconv.Itoa(oldLine) entry.NewLine = strconv.Itoa(newLine) oldLine++ newLine++ default: entry.Kind = "meta" } out = append(out, entry) } return out } func parseDiffHunkHeader(line string) (int, int) { oldStart, newStart := 0, 0 for _, field := range strings.Fields(line) { if strings.HasPrefix(field, "-") { oldStart = parseDiffRangeStart(strings.TrimPrefix(field, "-")) } if strings.HasPrefix(field, "+") { newStart = parseDiffRangeStart(strings.TrimPrefix(field, "+")) } } return oldStart, newStart } func parseDiffRangeStart(s string) int { if before, _, ok := strings.Cut(s, ","); ok { s = before } n, _ := strconv.Atoi(s) return n } func gitReadBlob(repoPath, ref, p string) (string, error) { out, err := gitReadBlobBytes(repoPath, ref, p) if err != nil { return "", err } if strings.Contains(string(out), "\x00") || !utf8.Valid(out) { return "", fmt.Errorf("binary file cannot be displayed") } return string(out), nil } func gitReadBlobBytes(repoPath, ref, p string) ([]byte, error) { if !branchRE.MatchString(ref) { return nil, errors.New("invalid branch") } spec := ref + ":" + p sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput() if err != nil { return nil, fmt.Errorf("file not found") } size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) if size > maxWebFileBytes { return nil, fmt.Errorf("file is too large to display") } out, err := exec.Command("git", "--git-dir", repoPath, "show", spec).CombinedOutput() if err != nil { return nil, fmt.Errorf("file not found") } return out, nil } func isMarkdownPath(p string) bool { switch strings.ToLower(filepath.Ext(p)) { case ".md", ".markdown", ".mdown", ".mkd": return true default: return false } } func isWebImagePath(p string) bool { switch strings.ToLower(filepath.Ext(p)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": return true default: return false } } func renderMarkdown(s string) (template.HTML, error) { var buf bytes.Buffer if err := goldmark.Convert([]byte(s), &buf); err != nil { return "", err } return template.HTML(buf.String()), nil } func defaultEditCommitMessage(p string) string { if p == "" { return "Create file" } return "Edit " + p } func editCommitFields(p, message, description string) (string, string, error) { message = strings.TrimSpace(message) description = strings.TrimSpace(description) if message == "" { message = defaultEditCommitMessage(p) } if strings.ContainsAny(message, "\x00\r\n") { return "", "", errors.New("commit message must be a single line") } if strings.Contains(description, "\x00") { return "", "", errors.New("commit description is invalid") } return message, description, nil } func (s *Server) commitEditedFile(repo Repository, ref, p, content, message, description string, user User) error { if !branchRE.MatchString(ref) { return errors.New("invalid branch") } work := filepath.Join(os.TempDir(), fmt.Sprintf("gitocean-edit-%d", time.Now().UnixNano())) defer os.RemoveAll(work) if err := gitRunOutput("", "clone", s.repoPath(repo.Owner, repo.Name), work); err != nil { return err } if err := gitRunOutput(work, "config", "user.name", user.Username); err != nil { return err } if err := gitRunOutput(work, "config", "user.email", user.Email); err != nil { return err } if gitBranchExists(s.repoPath(repo.Owner, repo.Name), ref) { if err := gitRunOutput(work, "checkout", "-B", ref, "origin/"+ref); err != nil { return err } } else { if err := gitRunOutput(work, "checkout", "--orphan", ref); err != nil { return err } _ = gitRunOutput(work, "rm", "-rf", ".") } full := filepath.Join(work, filepath.FromSlash(p)) if !strings.HasPrefix(full, work+string(os.PathSeparator)) { return errors.New("invalid path") } if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { return err } if err := os.WriteFile(full, []byte(content), 0644); err != nil { return err } if err := gitRunOutput(work, "add", filepath.FromSlash(p)); err != nil { return err } args := []string{"commit", "-m", message} if description != "" { args = append(args, "-m", description) } if err := gitRunOutput(work, args...); err != nil { if strings.Contains(err.Error(), "nothing to commit") { return nil } return err } return gitRunOutput(work, "push", "origin", "HEAD:"+ref) } func createGitBranchFrom(repoPath, branch, from string) error { cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "refs/heads/"+from) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("source branch does not exist: %s", strings.TrimSpace(string(out))) } commit := strings.TrimSpace(string(out)) return gitRunOutput("", "--git-dir", repoPath, "update-ref", "refs/heads/"+branch, commit) } func deleteGitBranch(repoPath, branch string) error { return gitRunOutput("", "--git-dir", repoPath, "update-ref", "-d", "refs/heads/"+branch) } func createEmptyGitBranch(repoPath, branch string, user User) error { work := filepath.Join(os.TempDir(), fmt.Sprintf("gitocean-empty-branch-%d", time.Now().UnixNano())) defer os.RemoveAll(work) if err := gitRunOutput("", "init", work); err != nil { return err } if err := gitRunOutput(work, "config", "user.name", user.Username); err != nil { return err } if err := gitRunOutput(work, "config", "user.email", user.Email); err != nil { return err } if err := gitRunOutput(work, "checkout", "--orphan", branch); err != nil { return err } if err := gitRunOutput(work, "commit", "--allow-empty", "-m", "Create empty branch "+branch); err != nil { return err } if err := gitRunOutput(work, "remote", "add", "origin", repoPath); err != nil { return err } return gitRunOutput(work, "push", "origin", "HEAD:"+branch) } func gitRunOutput(dir string, args ...string) error { cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) } return nil }