josie / simplegit

package web

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"

	"git.josie-c.com/josie/simplegit/internal/db"
	"git.josie-c.com/josie/simplegit/internal/git"
)

// Repo names become URL path segments (/user/repo), same policy as usernames.
var repoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)

type newRepoData struct {
	Username    string
	Name        string
	Description string
	Visibility  string
	Error       string
}

type createdData struct {
	Username string
	RepoName string
	PushURL  string
}

func (s *Server) handleNewRepoForm(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	s.render(w, "new.html", http.StatusOK, newRepoData{Username: user.Username, Visibility: visibilityPrivate})
}

// Repo creation is owner-only: anonymous visitors are redirected to the
// login page, so unauthenticated traffic can never materialize repos (or
// their on-disk directories) on the server.
func (s *Server) handleCreateRepo(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	// Truncate before validating so an oversized form value can never reach
	// the error page or the log unbounded.
	name := formText(r, "name", maxNameLen+1)
	description := formText(r, "description", maxTitleLen)
	visibility := r.FormValue("visibility")
	if visibility != visibilityPublic {
		visibility = visibilityPrivate
	}
	fail := func(status int, msg string) {
		log.Printf("web: create repo %q: %s", name, msg)
		s.render(w, "new.html", status, newRepoData{
			Username: user.Username,
			Name:     name, Description: description, Visibility: visibility, Error: msg,
		})
	}

	if len(name) > maxNameLen || !repoNamePattern.MatchString(name) {
		fail(http.StatusUnprocessableEntity, fmt.Sprintf("invalid repository name %q", name))
		return
	}
	if _, err := db.GetRepoByName(s.database, user.Username, name); err == nil {
		fail(http.StatusUnprocessableEntity, "you already have a repository with that name")
		return
	} else if !errors.Is(err, db.ErrNotFound) {
		s.internalError(w, r, err)
		return
	}

	// DB row first, bare repo second: if git fails we roll the row back,
	// whereas a stray directory from a failed run would shadow a future create.
	repo, err := db.CreateRepo(s.database, user.ID, name, description, visibility)
	if err != nil {
		fail(http.StatusInternalServerError, "could not create the repository")
		return
	}
	repoPath := filepath.Join(s.cfg.DataDir, "repos", user.Username, repo.Name+".git")
	if err := git.InitBare(repoPath, repo.DefaultBranch); err != nil {
		log.Printf("web: git init %s: %v", repoPath, err)
		if err := db.DeleteRepo(s.database, repo.ID); err != nil {
			log.Printf("web: rollback repo %d: %v", repo.ID, err)
		}
		os.RemoveAll(repoPath)
		fail(http.StatusInternalServerError, "could not initialize the repository on disk")
		return
	}

	pushURL := cloneURL(s.cfg.BaseURL, user.Username, repo.Name)
	s.render(w, "created.html", http.StatusOK, createdData{Username: user.Username, RepoName: repo.Name, PushURL: pushURL})
}