1799 lines
60 KiB
Go
1799 lines
60 KiB
Go
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
|
|
DiffTruncated bool
|
|
}
|
|
|
|
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"}}<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>{{.Title}} - gitocean</title></head>
|
|
<body>
|
|
<header style="margin-bottom: 1.5rem">
|
|
<strong><a href="/repos">gitocean</a></strong>
|
|
<span style="margin-left: 1rem"><a href="/repos">repos</a></span>
|
|
{{if .Authed}}
|
|
<span style="margin-left: 1rem"><a href="/repos/new">new repo</a></span>
|
|
<span style="margin-left: 1rem">logged in as {{.User.Username}}</span>
|
|
<form method="post" action="/logout" style="display:inline; margin-left: 1rem"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">logout</button></form>
|
|
{{else}}
|
|
<span style="margin-left: 1rem"><a href="/login">login</a></span>
|
|
<span style="margin-left: 1rem"><a href="/register">register</a></span>
|
|
{{end}}
|
|
</header>
|
|
{{if .Error}}<p style="color: darkred">{{.Error}}</p>{{end}}
|
|
{{.Content}}
|
|
</body></html>{{end}}
|
|
|
|
{{define "Repos"}}
|
|
<h1>Repositories</h1>
|
|
<form method="get" action="/repos" style="margin-bottom: 1rem">
|
|
<input name="q" value="{{.Data.Query}}" placeholder="search repositories">
|
|
<button type="submit">search</button>
|
|
</form>
|
|
{{if .Data.Repos}}
|
|
<table cellpadding="6">
|
|
<tr><th align="left">Repository</th><th align="left">Visibility</th><th align="left">Description</th></tr>
|
|
{{range .Data.Repos}}
|
|
<tr><td><a href="/{{.Owner}}/{{.Name}}">{{.Owner}}/{{.Name}}</a></td><td>{{.Visibility}}</td><td>{{.Description}}</td></tr>
|
|
{{end}}
|
|
</table>
|
|
{{else}}<p>No repositories found.</p>{{end}}
|
|
{{end}}
|
|
|
|
{{define "Login"}}
|
|
<h1>Login</h1>
|
|
<form method="post" action="/login">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Username or email<br><input name="login" autofocus></label></p>
|
|
<p><label>Password<br><input name="password" type="password"></label></p>
|
|
<p><button type="submit">login</button></p>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "Register"}}
|
|
<h1>Register</h1>
|
|
<form method="post" action="/register">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Email<br><input name="email" type="email" autofocus></label></p>
|
|
<p><label>Username<br><input name="username"></label></p>
|
|
<p><label>Password<br><input name="password" type="password"></label></p>
|
|
<p><button type="submit">register</button></p>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "New Repository"}}
|
|
<h1>New repository</h1>
|
|
<form method="post" action="/repos/new">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Name<br><input name="name" autofocus></label></p>
|
|
<p><label>Visibility<br><select name="visibility"><option value="public">public</option><option value="private">private</option></select></label></p>
|
|
<p><label>Description<br><textarea name="description" rows="4" cols="80"></textarea></label></p>
|
|
<p><button type="submit">create</button></p>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "Repository"}}
|
|
{{$d := .Data}}
|
|
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<p>{{$d.Repo.Visibility}} repository{{if $d.Repo.Archived}}; archived{{end}}</p>
|
|
{{if $d.Repo.Description}}<p>{{$d.Repo.Description}}</p>{{end}}
|
|
<p>Clone: <code>{{$d.CloneURL}}</code></p>
|
|
{{if $d.CanFork}}<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/fork" style="margin-bottom: 1rem"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">fork</button></form>{{end}}
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches">branches</a> | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls">pull requests</a> {{if .Authed}}| <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">new pull request</a>{{end}}</p>
|
|
<form method="get" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree" style="margin-bottom: 1rem">
|
|
<label>Branch
|
|
<select name="ref">
|
|
{{range $d.Branches}}<option value="{{.Name}}" {{if eq .Name $d.Ref}}selected{{end}}>{{.Name}}</option>{{end}}
|
|
</select>
|
|
</label>
|
|
<input type="hidden" name="path" value="{{$d.Path}}">
|
|
<button type="submit">switch</button>
|
|
</form>
|
|
{{if $d.LatestCommit}}
|
|
<p>Latest commit on {{$d.Ref}}: <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commit?ref={{urlquery $d.Ref}}&id={{urlquery $d.LatestCommit.Hash}}"><code>{{shortHash $d.LatestCommit.Hash}}</code> {{truncate $d.LatestCommit.Subject 72}}</a></p>
|
|
{{end}}
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits?ref={{urlquery $d.Ref}}">{{$d.CommitCount}} commits</a> on {{$d.Ref}}</p>
|
|
{{if $d.CanWrite}}
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}">create/edit file</a></p>
|
|
{{end}}
|
|
<p>Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}</p>
|
|
{{if $d.ParentPath}}<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.ParentPath}}">..</a></p>{{end}}
|
|
{{if $d.Entries}}
|
|
<table cellpadding="6">
|
|
<tr><th align="left">Name</th><th align="left">Type</th><th align="right">Size</th><th></th></tr>
|
|
{{range $d.Entries}}
|
|
<tr>
|
|
<td>{{if eq .Type "tree"}}<a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery .Path}}">{{.Name}}/</a>{{else}}<a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/blob?ref={{urlquery $d.Ref}}&path={{urlquery .Path}}">{{.Name}}</a>{{end}}</td>
|
|
<td>{{.Type}}</td><td align="right">{{.Size}}</td>
|
|
<td>{{if and $d.CanWrite (ne .Type "tree")}}<a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}&path={{urlquery .Path}}">edit</a>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
{{else}}<p>No files yet.</p>{{end}}
|
|
{{end}}
|
|
|
|
{{define "Branches"}}
|
|
{{$d := .Data}}
|
|
<h1>Branches for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}">repo</a></p>
|
|
{{if $d.Branches}}
|
|
<table cellpadding="6"><tr><th align="left">Branch</th><th align="left">Commit</th><th></th></tr>
|
|
{{range $d.Branches}}
|
|
<tr>
|
|
<td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery .Name}}">{{.Name}}</a>{{if eq .Name $d.Repo.DefaultBranch}} (default){{end}}</td>
|
|
<td><code>{{.Commit}}</code></td>
|
|
<td>{{if and $d.CanWrite (ne .Name $d.Repo.DefaultBranch)}}<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches/delete" style="display:inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="name" value="{{.Name}}"><button type="submit">delete</button></form>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
{{else}}<p>No branches yet.</p>{{end}}
|
|
{{if $d.CanWrite}}
|
|
<h2>New branch</h2>
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Name<br><input name="name" placeholder="branch-name"></label></p>
|
|
<p><label>From<br><select name="from"><option value="">empty branch</option>{{range $d.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label></p>
|
|
<p><button type="submit">create branch</button></p>
|
|
</form>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{define "Commit History"}}
|
|
{{$d := .Data}}
|
|
<h1>Commits for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}">repo</a></p>
|
|
<form method="get" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits" style="margin-bottom: 1rem">
|
|
<label>Branch
|
|
<select name="ref">
|
|
{{range $d.Branches}}<option value="{{.Name}}" {{if eq .Name $d.Ref}}selected{{end}}>{{.Name}}</option>{{end}}
|
|
</select>
|
|
</label>
|
|
<button type="submit">switch</button>
|
|
</form>
|
|
{{if $d.Commits}}
|
|
<table cellpadding="6"><tr><th align="left">Commit</th><th align="left">Author</th><th align="left">Date</th></tr>
|
|
{{range $d.Commits}}
|
|
<tr>
|
|
<td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commit?ref={{urlquery $d.Ref}}&id={{urlquery .Hash}}"><code>{{shortHash .Hash}}</code> {{.Subject}}</a></td>
|
|
<td>{{.AuthorName}}</td>
|
|
<td>{{.Date}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
{{else}}<p>No commits.</p>{{end}}
|
|
{{end}}
|
|
|
|
{{define "Commit"}}
|
|
{{$d := .Data}}
|
|
<h1>Commit {{shortHash $d.Commit.Hash}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/commits?ref={{urlquery $d.Ref}}">commit history</a> | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}">tree</a></p>
|
|
<h2>{{$d.Commit.Subject}}</h2>
|
|
<p><code>{{$d.Commit.Hash}}</code></p>
|
|
<p>{{$d.Commit.AuthorName}} <{{$d.Commit.AuthorEmail}}> authored {{$d.Commit.Date}}</p>
|
|
{{if $d.Commit.Body}}<pre style="white-space: pre-wrap">{{$d.Commit.Body}}</pre>{{end}}
|
|
<h2>Diff</h2>
|
|
{{if $d.Commit.DiffTruncated}}<p>Diff truncated.</p>{{end}}
|
|
<pre style="white-space: pre-wrap">{{$d.Commit.Diff}}</pre>
|
|
{{end}}
|
|
|
|
{{define "File"}}
|
|
{{$d := .Data}}
|
|
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.ParentPath}}">back to tree</a>{{if $d.CanWrite}} | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}&path={{urlquery $d.Path}}">edit</a>{{end}}</p>
|
|
{{if $d.IsImage}}
|
|
<p><img src="{{$d.ImageURL}}" alt="{{$d.Path}}" style="max-width: 100%; height: auto"></p>
|
|
{{else if $d.IsMarkdown}}
|
|
<article>{{$d.RenderedMarkdown}}</article>
|
|
{{else}}
|
|
<pre style="white-space: pre-wrap">{{$d.Content}}</pre>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{define "Edit File"}}
|
|
{{$d := .Data}}
|
|
<h1>Edit {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}&path={{urlquery $d.Path}}">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Branch<br><input name="ref" value="{{$d.Ref}}"></label></p>
|
|
<p><label>Path<br><input name="path" value="{{$d.Path}}" size="80"></label></p>
|
|
<p><label>Content<br><textarea name="content" rows="24" cols="100">{{$d.Content}}</textarea></label></p>
|
|
<h2>Commit</h2>
|
|
<p><label>Commit message<br><input name="commit_message" value="{{$d.CommitMessage}}" size="80"></label></p>
|
|
<p><label>Extended description<br><textarea name="commit_description" rows="4" cols="80">{{$d.CommitDescription}}</textarea></label></p>
|
|
<p><button type="submit">commit changes</button></p>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "Pull Requests"}}
|
|
{{$d := .Data}}
|
|
<h1>Pull requests for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}">repo</a> {{if .Authed}}| <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">new pull request</a>{{end}}</p>
|
|
{{if $d.PRs}}
|
|
<table cellpadding="6"><tr><th align="left">#</th><th align="left">Title</th><th align="left">Status</th><th align="left">Branches</th></tr>
|
|
{{range $d.PRs}}<tr><td>#{{.Number}}</td><td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{.Number}}">{{.Title}}</a></td><td>{{.Status}}</td><td>{{.SourceOwner}}/{{.SourceRepo}}:{{.SourceBranch}} → {{.TargetBranch}}</td></tr>{{end}}
|
|
</table>
|
|
{{else}}<p>No pull requests.</p>{{end}}
|
|
{{end}}
|
|
|
|
{{define "New Pull Request"}}
|
|
{{$d := .Data}}
|
|
<h1>New pull request for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><label>Source owner<br><input name="source_owner" value="{{.User.Username}}"></label></p>
|
|
<p><label>Source repo<br><input name="source_repo" value="{{$d.Repo.Name}}"></label></p>
|
|
<p><label>Source branch<br><input name="source_branch"></label></p>
|
|
<p><label>Target branch<br><select name="target_branch">{{range $d.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label></p>
|
|
<p><label>Title<br><input name="title" size="80"></label></p>
|
|
<p><label>Description<br><textarea name="description" rows="6" cols="80"></textarea></label></p>
|
|
<p><button type="submit">create pull request</button></p>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "Pull Request"}}
|
|
{{$d := .Data}}
|
|
<h1>#{{$d.PR.Number}} {{$d.PR.Title}}</h1>
|
|
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls">pull requests</a> | {{$d.PR.Status}} | {{$d.PR.SourceOwner}}/{{$d.PR.SourceRepo}}:{{$d.PR.SourceBranch}} → {{$d.PR.TargetBranch}}</p>
|
|
{{if $d.PR.Description}}<p>{{$d.PR.Description}}</p>{{end}}
|
|
{{if $d.CanManage}}{{if eq $d.PR.Status "open"}}
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/merge" style="display:inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">merge</button></form>
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/close" style="display:inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">close</button></form>
|
|
{{end}}{{end}}
|
|
<h2>Comments</h2>
|
|
{{range $d.Comments}}<div style="margin-bottom: 1rem"><strong>{{.Author}}</strong><br><pre style="white-space: pre-wrap">{{.Body}}</pre></div>{{else}}<p>No comments.</p>{{end}}
|
|
{{if $d.CanComment}}
|
|
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/comments">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<p><textarea name="body" rows="5" cols="80"></textarea></p>
|
|
<p><button type="submit">comment</button></p>
|
|
</form>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{define "Profile"}}
|
|
{{$d := .Data}}
|
|
<h1>{{$d.Profile.Username}}</h1>
|
|
<p>{{$d.Profile.Email}}</p>
|
|
<h2>Public repositories</h2>
|
|
{{if $d.Repos}}
|
|
<table cellpadding="6"><tr><th align="left">Repository</th><th align="left">Description</th></tr>{{range $d.Repos}}<tr><td><a href="/{{.Owner}}/{{.Name}}">{{.Owner}}/{{.Name}}</a></td><td>{{.Description}}</td></tr>{{end}}</table>
|
|
{{else}}<p>No public repositories.</p>{{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, "<!doctype html><title>Error</title><h1>Error</h1><p>%s</p><p><a href=\"/repos\">repos</a></p>", 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.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 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
|
|
}
|