diff --git a/README.md b/README.md index 0a8188b..7db9446 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/app/repos.go b/internal/app/repos.go index 7844004..4c8ad59 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -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 { diff --git a/internal/app/routing.go b/internal/app/routing.go index 11295bc..555d09d 100644 --- a/internal/app/routing.go +++ b/internal/app/routing.go @@ -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) { diff --git a/internal/app/web.go b/internal/app/web.go new file mode 100644 index 0000000..1b5e0a2 --- /dev/null +++ b/internal/app/web.go @@ -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"}} +{{.Title}} - gitocean + +
+ gitocean + repos + {{if .Authed}} + new repo + logged in as {{.User.Username}} +
+ {{else}} + login + register + {{end}} +
+{{if .Error}}

{{.Error}}

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

Repositories

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

No repositories found.

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

Login

+
+ +

+

+

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

Register

+
+ +

+

+

+

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

New repository

+
+ +

+

+

+

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

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

+

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

+{{if $d.Repo.Description}}

{{$d.Repo.Description}}

{{end}} +

Clone: {{$d.CloneURL}}

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

create/edit file

{{end}} +

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

+{{if $d.ParentPath}}

..

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

No files yet.

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

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

+

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

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

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

+
+ +

+

+

+

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

Error

%s

repos

", template.HTMLEscapeString(msg)) +} + +func (s *Server) webRepos(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + like := "%" + q + "%" + user, authed := s.optionalWebUser(r) + var rows *sql.Rows + var err error + if authed { + rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at + FROM repositories r JOIN users u ON u.id = r.owner_user_id + WHERE (r.visibility = 'public' OR r.owner_user_id = ? OR EXISTS (SELECT 1 FROM repository_collaborators c WHERE c.repository_id = r.id AND c.user_id = ?)) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) + ORDER BY r.updated_at DESC LIMIT 100`, user.ID, user.ID, q, like, like) + } else { + rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at + FROM repositories r JOIN users u ON u.id = r.owner_user_id + WHERE r.visibility = 'public' AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) + ORDER BY r.updated_at DESC LIMIT 100`, q, like, like) + } + if err != nil { + webError(w, r, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + repos, err := scanRepos(rows) + if err != nil { + webError(w, r, http.StatusInternalServerError, err.Error()) + return + } + s.renderWeb(w, r, "Repos", webReposData{Query: q, Repos: repos}, "") +} + +func (s *Server) webLoginPost(w http.ResponseWriter, r *http.Request) { + if !validWebCSRF(r) { + s.renderWeb(w, r, "Login", nil, "invalid form token") + return + } + login := strings.ToLower(strings.TrimSpace(r.FormValue("login"))) + password := r.FormValue("password") + var user User + var hash string + err := s.db.QueryRow(`SELECT id, email, username, is_admin, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin, &hash) + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + s.renderWeb(w, r, "Login", nil, "invalid credentials") + return + } + token, _, err := s.createToken(user.ID) + if err != nil { + s.renderWeb(w, r, "Login", nil, "could not create session") + return + } + setWebAuthCookie(w, r, token) + http.Redirect(w, r, "/repos", http.StatusSeeOther) +} + +func (s *Server) webRegisterPost(w http.ResponseWriter, r *http.Request) { + if !validWebCSRF(r) { + s.renderWeb(w, r, "Register", nil, "invalid form token") + return + } + email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) + username := strings.ToLower(strings.TrimSpace(r.FormValue("username"))) + password := r.FormValue("password") + if !strings.Contains(email, "@") || len(email) > 255 { + s.renderWeb(w, r, "Register", nil, "invalid email") + return + } + if !usernameRE.MatchString(username) || isReservedName(username) { + s.renderWeb(w, r, "Register", nil, "invalid or reserved username") + return + } + if len(password) < 8 { + s.renderWeb(w, r, "Register", nil, "password must be at least 8 characters") + return + } + var userCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) + isAdmin := userCount == 0 + if err := createUserDirect(s.db, email, username, password, isAdmin); err != nil { + s.renderWeb(w, r, "Register", nil, "email or username already exists") + return + } + var userID int64 + if err := s.db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&userID); err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + token, _, err := s.createToken(userID) + if err == nil { + setWebAuthCookie(w, r, token) + } + http.Redirect(w, r, "/repos", http.StatusSeeOther) +} + +func (s *Server) webLogoutPost(w http.ResponseWriter, r *http.Request) { + if !validWebCSRF(r) { + webError(w, r, http.StatusBadRequest, "invalid form token") + return + } + if c, err := r.Cookie(webAuthCookie); err == nil && c.Value != "" { + _, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(c.Value)) + } + clearCookie(w, webAuthCookie) + http.Redirect(w, r, "/repos", http.StatusSeeOther) +} + +func (s *Server) webRepoNew(w http.ResponseWriter, r *http.Request, errMsg string) { + if _, ok := s.optionalWebUser(r); !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + s.renderWeb(w, r, "New Repository", nil, errMsg) +} + +func (s *Server) webRepoNewPost(w http.ResponseWriter, r *http.Request) { + if !validWebCSRF(r) { + s.webRepoNew(w, r, "invalid form token") + return + } + user, ok := s.optionalWebUser(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) + visibility := strings.ToLower(strings.TrimSpace(r.FormValue("visibility"))) + description := strings.TrimSpace(r.FormValue("description")) + if !repoNameRE.MatchString(name) || isReservedName(name) { + s.webRepoNew(w, r, "invalid repository name") + return + } + if visibility != "public" && visibility != "private" { + s.webRepoNew(w, r, "visibility must be public or private") + return + } + res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch) VALUES (?, ?, ?, ?, 'main')`, user.ID, name, visibility, description) + if err != nil { + s.webRepoNew(w, r, "repository already exists") + return + } + repoID, _ := res.LastInsertId() + repoPath := s.repoPath(user.Username, name) + if err := os.MkdirAll(filepath.Dir(repoPath), 0755); err != nil { + _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) + s.webRepoNew(w, r, err.Error()) + return + } + if err := gitInitBare(repoPath); err != nil { + _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) + s.webRepoNew(w, r, err.Error()) + return + } + http.Redirect(w, r, "/"+user.Username+"/"+name, http.StatusSeeOther) +} + +func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []string) { + if len(parts) < 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 +} diff --git a/internal/app/web_test.go b/internal/app/web_test.go new file mode 100644 index 0000000..7a1fdd5 --- /dev/null +++ b/internal/app/web_test.go @@ -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(), "

Login

") { + 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) + } +} diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index ed113b3..36ae90c 100644 --- a/notes/PHASE_4_COVERAGE_NOTES.md +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -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% diff --git a/plans/PHASE_5_WEB_UI_PLAN.md b/plans/PHASE_5_WEB_UI_PLAN.md new file mode 100644 index 0000000..458c1e1 --- /dev/null +++ b/plans/PHASE_5_WEB_UI_PLAN.md @@ -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