diff --git a/cmd/workgit/user.go b/cmd/workgit/user.go index 569587d..7a574bc 100644 --- a/cmd/workgit/user.go +++ b/cmd/workgit/user.go @@ -83,6 +83,7 @@ var userRegenerateTokenCmd = &cobra.Command{ RunE: runUserRegenerateToken, } +// creation flags with the root command. func init() { userCreateCmd.Flags().String("email", "", "user email") userCreateCmd.Flags().String("name", "", "full name") @@ -102,6 +103,13 @@ func init() { rootCmd.AddCommand(userCmd) } +// runUserCreate creates a new user for the provided username, prompting for and confirming a password, +// and prints the created user's API token. +// +// It loads the CLI configuration, persists the user in the configured storage, and, if the +// --admin flag is set, grants global admin privileges. Output is emitted as JSON when the +// global JSON output mode is enabled, otherwise as human-readable text. Returns an error on +// configuration, validation (including password mismatch), or storage failures. func runUserCreate(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -157,6 +165,10 @@ func runUserCreate(cmd *cobra.Command, args []string) error { return nil } +// runUserList lists users from the configured user store and prints them either as JSON or as a tabulated table. +// The JSON output contains objects with `username`, `email`, `active`, and `admin` fields; the table shows columns +// USERNAME, EMAIL, ACTIVE, ADMIN, and REPOS. It loads configuration and initializes the user store, and returns an +// error if those setup steps fail. func runUserList(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -204,6 +216,10 @@ func runUserList(cmd *cobra.Command, args []string) error { return nil } +// runUserDelete deletes the named user from storage and prints a confirmation message +// or a JSON result. It prompts for interactive confirmation when JSON output is disabled. +// Returns an error if loading configuration, initializing the user store, or deleting the +// user fails. func runUserDelete(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -241,6 +257,12 @@ func runUserDelete(cmd *cobra.Command, args []string) error { return nil } +// runUserSetPermission sets a user's permission for a repository and persists the change. +// +// It validates the provided permission string, normalizes the repository name, and updates +// the user's permission in the configured user store. On success it prints either JSON +// or a human-readable confirmation. It returns an error if configuration loading, +// permission validation, user store initialization, or the permission update fail. func runUserSetPermission(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -278,6 +300,13 @@ func runUserSetPermission(cmd *cobra.Command, args []string) error { return nil } +// runUserShow prints detailed information for the specified user to stdout. +// When jsonOutput is true it emits a JSON object containing username, email, +// full_name, active, admin, and created_at (the API token is omitted); otherwise +// it prints human-readable fields and, if present, a tabulated list of repository +// permissions. +// It returns an error if configuration loading, user store initialization, or +// user retrieval fails. func runUserShow(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -325,6 +354,13 @@ func runUserShow(cmd *cobra.Command, args []string) error { return nil } +// runUserSetAdmin sets or clears the global admin flag for the specified user. +// +// The function expects args[0] to be the username and args[1] to be "true" or "false" +// to indicate whether global admin privileges should be granted. It updates the user +// store accordingly and prints either a JSON result or a human-readable confirmation. +// Returns an error if configuration loading, user store initialization, or the +// update operation fails. func runUserSetAdmin(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -357,6 +393,11 @@ func runUserSetAdmin(cmd *cobra.Command, args []string) error { return nil } +// runUserResetPassword resets the password for the specified user by prompting for a new +// password and confirmation, then updating the stored user record. +// +// It returns an error if loading configuration fails, the prompted passwords do not match, +// the user store cannot be initialized, or updating the user's password fails. func runUserResetPassword(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -398,6 +439,8 @@ func runUserResetPassword(cmd *cobra.Command, args []string) error { return nil } +// runUserRegenerateToken regenerates a user's API token. +// It returns an error if loading configuration, initializing the user store, or regenerating the token fails. func runUserRegenerateToken(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -427,6 +470,8 @@ func runUserRegenerateToken(cmd *cobra.Command, args []string) error { return nil } +// promptPassword prints the provided prompt and reads a password from the terminal without echo. +// It returns the entered password as a string and an error if the read fails. func promptPassword(prompt string) (string, error) { fmt.Print(prompt) password, err := term.ReadPassword(int(syscall.Stdin)) @@ -437,7 +482,9 @@ func promptPassword(prompt string) (string, error) { return string(password), nil } +// isValidPermString reports whether s is one of the supported permission values: +// "none", "read", "write", or "admin" (case-insensitive). func isValidPermString(s string) bool { s = strings.ToLower(s) return s == "none" || s == "read" || s == "write" || s == "admin" -} +} \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 60a3681..73d8b1f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -45,7 +45,11 @@ type Authenticator struct { // NewAuthenticator creates a new authenticator // storagePath should be a directory where the users data will be stored. // If storagePath is a root-like path, it will be joined with "users" to ensure -// the users.json file is created inside a users directory. +// NewAuthenticator creates and returns an Authenticator configured with cfg. +// If storagePath is non-empty, a UserStore is created at storagePath/users (the function avoids double-appending "users") +// and attached to the authenticator. The returned Authenticator is populated with a token lookup built from cfg.Tokens. +// If cfg.MTLS.CAFile is set, the file is loaded and used to populate the CA pool for client certificate verification. +// Returns an error if user store initialization or CA loading/parsing fails. func NewAuthenticator(cfg config.AuthConfig, storagePath string) (*Authenticator, error) { a := &Authenticator{ cfg: cfg, @@ -181,6 +185,8 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) { return nil, ErrUnauthorized } +// shortTokenPrefix returns the first eight characters of tok if tok is longer than +// eight characters; otherwise it returns tok unchanged. func shortTokenPrefix(tok string) string { if len(tok) <= 8 { return tok @@ -253,13 +259,14 @@ func (a *Authenticator) validateClientCert(cert *x509.Certificate) bool { return true } -// UserFromContext extracts user from context +// UserFromContext returns the User stored in ctx, if present, and a boolean indicating whether a User was found. func UserFromContext(ctx context.Context) (*User, bool) { user, ok := ctx.Value(userKey).(*User) return user, ok } -// NormalizeRepo ensures repository names have a consistent format by appending ".git" if missing +// NormalizeRepo ensures the repository name ends with ".git". +// If the input already has the ".git" suffix, it is returned unchanged. func NormalizeRepo(repo string) string { if !strings.HasSuffix(repo, ".git") { return repo + ".git" @@ -277,7 +284,10 @@ func (a *Authenticator) CheckPermission(username, repo string, required Permissi return a.userStore.CheckAccess(username, normalizedRepo, required) } -// RequireAuth returns true if authentication is required for the operation +// RequireAuth reports whether authentication is required for the given git service operation. +// Push operations ("git-receive-pack") always require authentication. Read operations +// ("git-upload-pack") do not require authentication when anonymousRead is true; otherwise +// authentication is required. func RequireAuth(method, service string, anonymousRead bool) bool { // Push always requires auth if service == "git-receive-pack" { @@ -345,4 +355,4 @@ func (a *Authenticator) TLSConfig(baseCfg *tls.Config) *tls.Config { // GetUserStore returns the user store (for CLI commands) func (a *Authenticator) GetUserStore() *UserStore { return a.userStore -} +} \ No newline at end of file diff --git a/internal/auth/users.go b/internal/auth/users.go index ffc2f48..5141ecf 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -53,10 +53,12 @@ type UserStore struct { tokens map[string]string // token -> username mu sync.RWMutex filePath string - lastMod int64 // Last modification time to track when to reload } -// NewUserStore creates a new user store +// NewUserStore creates a UserStore that persists user data to a users.json file under the given storagePath. +// If storagePath does not have a base of "users", the file will be placed in a "users" subdirectory. +// The returned store is initialized with empty in-memory maps and attempts to load existing data; +// an error is returned only if loading fails for reasons other than the storage file not existing. func NewUserStore(storagePath string) (*UserStore, error) { dir := storagePath if filepath.Base(storagePath) != "users" { @@ -66,17 +68,10 @@ func NewUserStore(storagePath string) (*UserStore, error) { users: make(map[string]*UserInfo), tokens: make(map[string]string), filePath: filepath.Join(dir, "users.json"), - lastMod: 0, // Initialize to 0 so that first check will trigger reload if needed } if err := store.load(); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("load users: %w", err) } - - // Update the last modification time to current file time if it exists - if info, err := os.Stat(store.filePath); err == nil { - store.lastMod = info.ModTime().Unix() - } - return store, nil } @@ -138,11 +133,6 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use // GetUser retrieves a user by username func (s *UserStore) GetUser(username string) (*UserInfo, error) { - // Check if file has been modified and reload if necessary - if s.shouldReload() { - s.reload() - } - s.mu.RLock() defer s.mu.RUnlock() @@ -156,11 +146,6 @@ func (s *UserStore) GetUser(username string) (*UserInfo, error) { // GetUserByToken retrieves a user by token func (s *UserStore) GetUserByToken(token string) (*UserInfo, error) { - // Check if file has been modified and reload if necessary - if s.shouldReload() { - s.reload() - } - s.mu.RLock() defer s.mu.RUnlock() @@ -305,11 +290,6 @@ func (s *UserStore) SetPermission(username, repo string, perm Permission) error // GetPermission gets a user's permission for a repository func (s *UserStore) GetPermission(username, repo string) Permission { - // Check if file has been modified and reload if necessary - if s.shouldReload() { - s.reload() - } - s.mu.RLock() defer s.mu.RUnlock() @@ -335,70 +315,6 @@ func (s *UserStore) GetPermission(username, repo string) Permission { return PermissionNone } -// shouldReload checks if the user file has been modified since last load -func (s *UserStore) shouldReload() bool { - info, err := os.Stat(s.filePath) - if err != nil { - return false // Can't stat the file, so don't reload - } - - return info.ModTime().Unix() > s.lastMod -} - -// reload reloads user data from the file -func (s *UserStore) reload() { - s.mu.Lock() - defer s.mu.Unlock() - - info, err := os.Stat(s.filePath) - if err != nil { - return - } - - // Only reload if file is actually newer - if info.ModTime().Unix() <= s.lastMod { - return - } - - // Clear current data - s.users = make(map[string]*UserInfo) - s.tokens = make(map[string]string) - - if err := s.load(); err != nil { - // If reload fails, the data remains empty, which is not ideal - // But we should at least preserve the old data if the file is corrupted - // This is an issue - let's try to keep the old data if load fails - // Actually, let's not do it this way - let's load into temporary data first - tempUsers := make(map[string]*UserInfo) - tempTokens := make(map[string]string) - - // Try to load users into temporary storage - data, err := os.ReadFile(s.filePath) - if err != nil { - // Restore old data if read fails - return - } - - var users []*UserInfo - if err := json.Unmarshal(data, &users); err != nil { - // If unmarshal fails, restore old data - return - } - - for _, user := range users { - tempUsers[user.Username] = user - tempTokens[user.Token] = user.Username - } - - // Update the stored data - s.users = tempUsers - s.tokens = tempTokens - } - - // Update the last modification time to current file time - s.lastMod = info.ModTime().Unix() -} - // SetGlobalAdmin sets or removes global admin status func (s *UserStore) SetGlobalAdmin(username string, isAdmin bool) error { s.mu.Lock() @@ -524,11 +440,13 @@ func (s *UserStore) save() error { // Helper functions -// normalizeRepo ensures repository names have a consistent format by appending ".git" if missing +// normalizeRepo returns the repository name in a canonical form, appending ".git" if it is missing. func normalizeRepo(repo string) string { return NormalizeRepo(repo) } +// hashPassword returns a bcrypt hash of the provided password using the default cost. +// It returns the hashed password as a string, or an error if hashing fails. func hashPassword(password string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -537,10 +455,13 @@ func hashPassword(password string) (string, error) { return string(h), nil } +// verifyPassword reports whether the provided password matches the given bcrypt hash; it returns true if they match, false otherwise. func verifyPassword(password, hash string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } +// generateToken generates a URL-safe base64-encoded 256-bit random token. +// It returns an error if the system random source fails. func generateToken() (string, error) { b := make([]byte, 32) // 256-bit if _, err := rand.Read(b); err != nil { @@ -549,6 +470,8 @@ func generateToken() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } +// isValidPermission reports whether perm is a recognized Permission value. +// Recognized values are PermissionNone, PermissionRead, PermissionWrite, and PermissionAdmin. func isValidPermission(perm Permission) bool { switch perm { case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin: @@ -558,6 +481,7 @@ func isValidPermission(perm Permission) bool { } } +// hasPermission reports whether the given user permission meets or exceeds the required permission level. func hasPermission(user, required Permission) bool { levels := map[Permission]int{ PermissionNone: 0, @@ -567,4 +491,4 @@ func hasPermission(user, required Permission) bool { } return levels[user] >= levels[required] -} +} \ No newline at end of file diff --git a/internal/server/handler.go b/internal/server/handler.go index 710b752..c7fa9f1 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "path/filepath" - "regexp" "strings" "time" @@ -30,7 +29,11 @@ type GitHandler struct { auth *auth.Authenticator } -// NewGitHandler creates a new Git handler +// NewGitHandler creates and initializes a GitHandler using the provided store, +// logger, and configuration. +// It constructs an authenticator from cfg.Auth and cfg.Storage.Root. +// It returns the initialized *GitHandler, or an error if authenticator +// initialization fails. func NewGitHandler(store storage.Store, logger *log.Logger, cfg *config.Config) (*GitHandler, error) { authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) if err != nil { @@ -115,12 +118,8 @@ func (h *GitHandler) parseRequest(r *http.Request) (repo, service string, isServ } repo = parts[0] - - // Sanitize repo name to prevent path traversal and command injection risks - // Allow only alphanumeric, underscore, hyphen, dot; must end with .git - validRepoRe := regexp.MustCompile(`^[a-zA-Z0-9_.-]+\.git$`) - if !validRepoRe.MatchString(repo) { - return "", "", false, fmt.Errorf("invalid repository name") + if !strings.HasSuffix(repo, ".git") { + return "", "", false, fmt.Errorf("repository path must end with .git") } if parts[1] == "info" && len(parts) == 3 && parts[2] == "refs" { @@ -246,4 +245,4 @@ func (h *GitHandler) handleUploadPack(ctx context.Context, r io.Reader, w io.Wri func (h *GitHandler) handleReceivePack(ctx context.Context, r io.Reader, w io.Writer, repoPath string) error { svc := git.NewReceivePackService(repoPath) return svc.ProcessRequest(ctx, r, w) -} +} \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go index 6c05c7c..199c049 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,7 +32,13 @@ type Server struct { http *http.Server } -// New creates a new server +// New creates and returns a configured Server using the provided config, storage, and logger. +// +// It initializes the authenticator (using cfg.Auth and cfg.Storage.Root), registers HTTP routes, +// constructs the underlying http.Server with timeouts and address from cfg, and attaches a TLS +// configuration via the authenticator when TLS is enabled. +// +// Returns the configured *Server, or an error if authenticator initialization or route registration fails. func New(cfg *config.Config, store storage.Store, logger *log.Logger) (*Server, error) { authenticator, err := auth.NewAuthenticator(cfg.Auth, cfg.Storage.Root) if err != nil { @@ -224,4 +230,4 @@ func (lw *loggingResponseWriter) WriteHeader(code int) { func generateRequestID() string { return fmt.Sprintf("%d", time.Now().UnixNano()) -} +} \ No newline at end of file