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"}} +
{{.Error}}
{{end}} +{{.Content}} +{{end}} + +{{define "Repos"}} +| Repository | Visibility | Description |
|---|---|---|
| {{.Owner}}/{{.Name}} | {{.Visibility}} | {{.Description}} |
No repositories found.
{{end}} +{{end}} + +{{define "Login"}} +{{$d.Repo.Visibility}} repository{{if $d.Repo.Archived}}; archived{{end}}
+{{if $d.Repo.Description}}{{$d.Repo.Description}}
{{end}} +Clone: {{$d.CloneURL}}
Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}
+{{if $d.ParentPath}}{{end}} +{{if $d.Entries}} +| Name | Type | Size | |
|---|---|---|---|
| {{if eq .Type "tree"}}{{.Name}}/{{else}}{{.Name}}{{end}} | +{{.Type}} | {{.Size}} | +{{if and $d.CanWrite (ne .Type "tree")}}edit{{end}} | +
No files yet.
{{end}} +{{end}} + +{{define "File"}} +{{$d := .Data}} +back to tree{{if $d.CanWrite}} | edit{{end}}
+{{$d.Content}}
+{{end}}
+
+{{define "Edit File"}}
+{{$d := .Data}}
+%s
", 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(), "