package app import ( "bytes" "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "html/template" "mime" "net/http" "net/url" "os" "os/exec" pathpkg "path" "path/filepath" "strconv" "strings" "time" "unicode/utf8" "github.com/yuin/goldmark" "golang.org/x/crypto/bcrypt" ) const ( webAuthCookie = "gitocean_web_token" webCSRFCookie = "gitocean_csrf" maxWebFileBytes = 1024 * 1024 ) 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 ParentPath string Content string RenderedMarkdown template.HTML IsMarkdown bool IsImage bool ImageURL string CanWrite bool } type webEditData struct { Repo Repository Ref string Path string Content string } 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}}
{{if $d.IsImage}}{{$d.Content}}
{{end}}
{{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 "raw": if r.Method != http.MethodGet { webError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } s.webRepoRaw(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 } data := webBlobData{Repo: repo, Ref: ref, Path: p, ParentPath: parentRepoPath(p), CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived} if isWebImagePath(p) { if _, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p); err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } data.IsImage = true data.ImageURL = "/" + repo.Owner + "/" + repo.Name + "/raw?ref=" + url.QueryEscape(ref) + "&path=" + url.QueryEscape(p) } else { content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } data.Content = content if isMarkdownPath(p) { md, err := renderMarkdown(content) if err != nil { webError(w, r, http.StatusInternalServerError, err.Error()) return } data.IsMarkdown = true data.RenderedMarkdown = md } } s.renderWeb(w, r, "File", data, "") } func (s *Server) webRepoRaw(w http.ResponseWriter, r *http.Request, owner, name string) { repo, _, _, ok := s.webRepoContext(w, r, owner, name) if !ok { return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), false) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } if !isWebImagePath(p) { webError(w, r, http.StatusBadRequest, "raw web rendering is only available for images") return } b, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(p))) if ct == "" { ct = http.DetectContentType(b) } w.Header().Set("Content-Type", ct) _, _ = w.Write(b) } func (s *Server) webRepoEdit(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) if !ok { return } if !authed { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !s.canWriteRepo(repo, user) || repo.Archived { webError(w, r, http.StatusForbidden, "write access required") return } ref := webRef(r, repo) p, err := cleanRepoFilePath(r.URL.Query().Get("path"), true) if err != nil { webError(w, r, http.StatusBadRequest, err.Error()) return } content := "" if p != "" { if c, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p); err == nil { content = c } } s.renderWeb(w, r, "Edit File", webEditData{Repo: repo, Ref: ref, Path: p, Content: content}, 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) { out, err := gitReadBlobBytes(repoPath, ref, p) if err != nil { return "", err } if strings.Contains(string(out), "\x00") || !utf8.Valid(out) { return "", fmt.Errorf("binary file cannot be displayed") } return string(out), nil } func gitReadBlobBytes(repoPath, ref, p string) ([]byte, error) { if !branchRE.MatchString(ref) { return nil, errors.New("invalid branch") } spec := ref + ":" + p sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput() if err != nil { return nil, fmt.Errorf("file not found") } size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) if size > maxWebFileBytes { return nil, fmt.Errorf("file is too large to display") } out, err := exec.Command("git", "--git-dir", repoPath, "show", spec).CombinedOutput() if err != nil { return nil, fmt.Errorf("file not found") } return out, nil } func isMarkdownPath(p string) bool { switch strings.ToLower(filepath.Ext(p)) { case ".md", ".markdown", ".mdown", ".mkd": return true default: return false } } func isWebImagePath(p string) bool { switch strings.ToLower(filepath.Ext(p)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": return true default: return false } } func renderMarkdown(s string) (template.HTML, error) { var buf bytes.Buffer if err := goldmark.Convert([]byte(s), &buf); err != nil { return "", err } return template.HTML(buf.String()), nil } func (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 }