Add minimal web UI

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent 73f1ae63cb
commit fca1dd89f2
7 changed files with 1012 additions and 5 deletions
+13 -1
View File
@@ -1,6 +1,6 @@
# Gitocean
Gitocean is a small Go-based Git hosting platform for personal use. It provides a CLI, JSON HTTP API, MySQL metadata storage, and HTTP Smart Git transport. There is no web UI and no SSH transport.
Gitocean is a small Go-based Git hosting platform for personal use. It provides a CLI, minimal server-rendered web UI, JSON HTTP API, MySQL metadata storage, and HTTP Smart Git transport. There is no SSH transport.
## Quickstart
@@ -18,6 +18,14 @@ go run ./cmd/gitocean repo create demo --public
go run ./cmd/gitocean clone USER/demo
```
Open the minimal web UI at the server URL, for example:
```text
http://localhost:8080/repos
```
Web UI routes include `/repos`, `/login`, `/register`, `/repos/new`, and `/{username}/{repo}`.
## Storage
Bare repositories are stored under:
@@ -38,6 +46,10 @@ CLI auth config defaults to:
~/.config/gitocean/config.json
```
## Web UI
The web UI is intentionally minimal and server-rendered. It supports repository search, login/register/logout, repository creation, repository file browsing, plain-text file viewing, simple textarea-based file editing for users with write access, and forking public repositories. Private repositories cannot be forked.
## Development
Run checks with:
+2 -2
View File
@@ -160,8 +160,8 @@ func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, n
writeError(w, http.StatusNotFound, "repository not found")
return
}
if src.Visibility == "private" && src.OwnerUserID != user.ID {
writeError(w, http.StatusForbidden, "cannot fork this private repository")
if src.Visibility != "public" {
writeError(w, http.StatusForbidden, "only public repositories can be forked")
return
}
var in struct {
+1 -1
View File
@@ -17,7 +17,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleGitHTTP(w, r)
return
}
writeError(w, http.StatusNotFound, "not found")
s.handleWeb(w, r)
}
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
+820
View File
@@ -0,0 +1,820 @@
package app
import (
"bytes"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
const (
webAuthCookie = "gitocean_web_token"
webCSRFCookie = "gitocean_csrf"
maxWebFileBytes = 1024 * 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
}
type webBlobData struct {
Repo Repository
Ref string
Path string
Content string
CanWrite bool
}
type webEditData struct {
Repo Repository
Ref string
Path string
Content string
}
type webTreeEntry struct {
Name string
Path string
Type string
Size string
}
var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
"urlquery": url.QueryEscape,
}).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}}
{{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 "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.Path}}">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>
<pre style="white-space: pre-wrap">{{$d.Content}}</pre>
{{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>
<p><button type="submit">commit changes</button></p>
</form>
{{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) < 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 "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 "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")
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)}
s.renderWeb(w, r, "Repository", data, "")
}
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
}
content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p)
if err != nil {
webError(w, r, http.StatusNotFound, err.Error())
return
}
data := webBlobData{Repo: repo, Ref: ref, Path: p, Content: content, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived}
s.renderWeb(w, r, "File", data, "")
}
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}, 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
}
if err := s.commitEditedFile(repo, ref, p, r.FormValue("content"), 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) 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 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 gitReadBlob(repoPath, ref, p string) (string, error) {
if !branchRE.MatchString(ref) {
return "", errors.New("invalid branch")
}
spec := ref + ":" + p
sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput()
if err != nil {
return "", fmt.Errorf("file not found")
}
size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64)
if size > maxWebFileBytes {
return "", fmt.Errorf("file is too large to display")
}
out, err := exec.Command("git", "--git-dir", repoPath, "show", spec).CombinedOutput()
if err != nil {
return "", fmt.Errorf("file not found")
}
if strings.Contains(string(out), "\x00") || !utf8.Valid(out) {
return "", fmt.Errorf("binary file cannot be displayed")
}
return string(out), nil
}
func (s *Server) commitEditedFile(repo Repository, ref, p, content 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
}
if err := gitRunOutput(work, "commit", "-m", "Edit "+p); err != nil {
if strings.Contains(err.Error(), "nothing to commit") {
return nil
}
return err
}
return gitRunOutput(work, "push", "origin", "HEAD:"+ref)
}
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
}
+144
View File
@@ -0,0 +1,144 @@
package app
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"golang.org/x/crypto/bcrypt"
)
func csrfFromResponse(t *testing.T, rr *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rr.Result().Cookies() {
if c.Name == webCSRFCookie {
return c
}
}
t.Fatalf("missing csrf cookie in %#v", rr.Result().Cookies())
return nil
}
func TestWebReposAndLogin(t *testing.T) {
s, mock, cleanup := newMockServer(t)
defer cleanup()
repo := Repository{ID: 10, OwnerUserID: 1, Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main"}
mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs("demo", "%demo%", "%demo%").WillReturnRows(repoRows(repo))
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/repos?q=demo", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "alice/demo") {
t.Fatalf("repos status=%d body=%s", rr.Code, rr.Body.String())
}
csrf := csrfFromResponse(t, rr)
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/login", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "<h1>Login</h1>") {
t.Fatalf("login GET status=%d body=%s", rr.Code, rr.Body.String())
}
hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
mock.ExpectQuery("SELECT id, email, username, is_admin, password_hash FROM users").WithArgs("alice", "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "password_hash"}).AddRow(int64(1), "alice@example.com", "alice", false, string(hash)))
mock.ExpectExec("INSERT INTO auth_tokens").WithArgs(int64(1), sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("_csrf="+csrf.Value+"&login=alice&password=password123"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/repos" {
t.Fatalf("login POST status=%d location=%q body=%s", rr.Code, rr.Header().Get("Location"), rr.Body.String())
}
foundAuth := false
for _, c := range rr.Result().Cookies() {
if c.Name == webAuthCookie && c.Value != "" && c.HttpOnly {
foundAuth = true
}
}
if !foundAuth {
t.Fatalf("missing auth cookie: %#v", rr.Result().Cookies())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
requireGitForApp(t)
s, mock, cleanup := newMockServer(t)
defer cleanup()
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
repo := Repository{ID: 10, OwnerUserID: user.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main", CreatedAt: time.Now(), UpdatedAt: time.Now()}
bare := s.repoPath(repo.Owner, repo.Name)
if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil {
t.Fatal(err)
}
seedRepoWithFeatureBranch(t, bare)
expectLoadRepo(mock, "alice", "demo", repo)
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "README.md") || !strings.Contains(rr.Body.String(), "Clone:") {
t.Fatalf("repo page status=%d body=%s", rr.Code, rr.Body.String())
}
csrf := csrfFromResponse(t, rr)
expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/blob?ref=main&path=README.md", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") {
t.Fatalf("blob page status=%d body=%s", rr.Code, rr.Body.String())
}
expectBearerUser(mock, "tok", user)
mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "newrepo", "public", "new desc").WillReturnResult(sqlmock.NewResult(20, 1))
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/repos/new", strings.NewReader("_csrf="+csrf.Value+"&name=newrepo&visibility=public&description=new+desc"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/newrepo" {
t.Fatalf("new repo status=%d location=%q body=%s", rr.Code, rr.Header().Get("Location"), rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
expectBearerUser(mock, "tok", user)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/alice/demo/edit", strings.NewReader("_csrf="+csrf.Value+"&ref=main&path=web.txt&content=from+web"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "web.txt") {
t.Fatalf("edit status=%d location=%q body=%s", rr.Code, rr.Header().Get("Location"), rr.Body.String())
}
content, err := gitReadBlob(bare, "main", "web.txt")
if err != nil || !strings.Contains(content, "from web") {
t.Fatalf("edited file content=%q err=%v", content, err)
}
privateRepo := repo
privateRepo.Visibility = "private"
expectBearerUser(mock, "tok", user)
expectLoadRepo(mock, "alice", "demo", privateRepo)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/alice/demo/fork", strings.NewReader("_csrf="+csrf.Value))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("private fork status=%d body=%s", rr.Code, rr.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation:
```text
internal/app 65.3%
internal/app 62.5%
internal/backup 42.3%
internal/config 79.5%
internal/dbutil 8.6%
+31
View File
@@ -0,0 +1,31 @@
# Phase 5 Web UI Plan
Goal: add a very small server-rendered web UI with minimal styling while preserving CLI/API behavior.
## Scope
- [x] `/repos` repository search/list page
- [x] `/login` login form with cookie auth
- [x] `/register` registration form
- [x] `/logout` form action
- [x] `/repos/new` repository creation form
- [x] `/{username}/{repo}` repository overview and root file tree
- [x] `/{username}/{repo}/tree` file tree browsing
- [x] `/{username}/{repo}/blob` file viewing
- [x] `/{username}/{repo}/edit` simple textarea editor for files
- [x] `/{username}/{repo}/fork` public-repository-only fork action
- [x] Minimal CSRF protection for cookie-backed POST forms
- [x] Tests for routing, auth forms, repository pages, and permission-sensitive actions
## Non-goals
- No JavaScript framework
- No rich CSS/theme system
- No SSH
- No CI/CD
- No advanced code review UI
## Completion
- [x] Implemented
- [x] Tests pass