GitRoot

Craft your forge, Build your project, Grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package user
  6
  7import (
  8	"bytes"
  9	"fmt"
 10	"slices"
 11	"strings"
 12
 13	"github.com/goccy/go-yaml"
 14	"github.com/goccy/go-yaml/parser"
 15	"github.com/samber/oops"
 16)
 17
 18type GitRootGroup struct {
 19	GroupName string
 20	Branches  []string
 21}
 22
 23type GitRootUser struct {
 24	Groups      []*GitRootGroup
 25	Email       string
 26	Pseudo      string
 27	OtherGroups []*GitRootGroup
 28	PubKey      string
 29}
 30
 31type Group struct {
 32	Name     string `yaml:"-"`
 33	Branches []Branch
 34	Users    []User
 35}
 36
 37type Branch struct {
 38	Name string
 39	Not  []string `yaml:",omitempty"`
 40	Only []string `yaml:",omitempty"`
 41}
 42
 43type User struct {
 44	Pseudo string
 45	Avatar string
 46	Emails []string
 47	Ssh    []string
 48}
 49
 50func ParseGroups(fileContent []byte) ([]Group, error) {
 51	groups := make([]Group, 0)
 52	groupsByName := make(map[string]Group)
 53	if err := yaml.UnmarshalWithOptions(fileContent, &groupsByName, yaml.UseOrderedMap()); err != nil {
 54		return nil, oops.Wrapf(err, "can't parse yaml users")
 55	}
 56	for name, group := range groupsByName {
 57		group.Name = name
 58		groups = append(groups, group)
 59	}
 60	return groups, nil
 61}
 62
 63func WriteGroups(groups []Group) ([]byte, error) {
 64	groupsByName := make(map[string]Group)
 65	for _, group := range groups {
 66		groupsByName[group.Name] = group
 67	}
 68	content, err := yaml.Marshal(groupsByName)
 69	return content, oops.Wrapf(err, "can't marshal yaml groups")
 70}
 71
 72type SimpleUser struct {
 73	Pseudo string
 74	Email  string
 75	Ssh    string
 76}
 77
 78func CreateFileUser(defaultBranch string, users ...SimpleUser) ([]byte, error) {
 79	u := make([]User, len(users))
 80	for i, j := range users {
 81		u[i] = User{
 82			Pseudo: j.Pseudo,
 83			Avatar: "",
 84			Emails: []string{j.Email},
 85			Ssh:    []string{j.Ssh},
 86		}
 87	}
 88	g := Group{
 89		Name:     "owner",
 90		Branches: []Branch{{Name: defaultBranch}},
 91		Users:    u,
 92	}
 93	content, err := yaml.Marshal(map[string]Group{"owner": g})
 94	return content, oops.Wrapf(err, "can't marshal yaml users")
 95}
 96
 97func AppendUserToGroup(file []byte, groupName string, users ...SimpleUser) ([]byte, error) {
 98	urlPath, err := yaml.PathString(fmt.Sprintf("$.%s.users", groupName))
 99	if err != nil {
100		return nil, oops.Wrapf(err, "invalid path yaml")
101	}
102	f, err := parser.ParseBytes(file, 0)
103	if err != nil {
104		return nil, oops.Wrapf(err, "can't parse file")
105	}
106	u := make([]User, 0)
107	for _, j := range users {
108		if !bytes.Contains(file, []byte(j.Ssh)) {
109			u = append(u, User{
110				Pseudo: j.Pseudo,
111				Avatar: "",
112				Emails: []string{j.Email},
113				Ssh:    []string{j.Ssh},
114			})
115		}
116	}
117	content, err := yaml.Marshal(u)
118	if err != nil {
119		return nil, oops.Wrapf(err, "can't marshal u")
120	}
121	err = urlPath.MergeFromReader(f, bytes.NewReader(content))
122	return []byte(f.String()), oops.Wrapf(err, "can't merge yaml")
123}
124
125func AppendBranchToGroup(file []byte, groupName string, branches ...string) ([]byte, error) {
126	urlPath, err := yaml.PathString(fmt.Sprintf("$.%s.branches", groupName))
127	if err != nil {
128		return nil, oops.Wrapf(err, "invalid path yaml")
129	}
130	f, err := parser.ParseBytes(file, 0)
131	if err != nil {
132		return nil, oops.Wrapf(err, "can't parse file")
133	}
134	u := make([]Branch, len(branches))
135	for i, j := range branches {
136		u[i] = Branch{
137			Name: j,
138		}
139	}
140	content, err := yaml.Marshal(u)
141	if err != nil {
142		return nil, oops.Wrapf(err, "can't marshal u")
143	}
144	err = urlPath.MergeFromReader(f, bytes.NewReader(content))
145	return []byte(f.String()), oops.Wrapf(err, "can't merge yaml")
146}
147
148func AppendBranch(file []byte, groupName string, users ...SimpleUser) ([]byte, error) {
149	u := make([]User, len(users))
150	for i, j := range users {
151		u[i] = User{
152			Pseudo: j.Pseudo,
153			Avatar: "",
154			Emails: []string{j.Email},
155			Ssh:    []string{j.Ssh},
156		}
157	}
158	group := Group{Name: groupName, Branches: []Branch{{Name: groupName}}, Users: u}
159	content, err := yaml.Marshal(map[string]Group{groupName: group})
160	if err != nil {
161		return nil, oops.Wrapf(err, "can't marshal u")
162	}
163
164	return AppendBranchToGroup(append(file, content...), "owner", groupName)
165}
166
167func DeleteGroupAndBranch(file []byte, groupName string) ([]byte, error) {
168	groups, err := ParseGroups(file)
169	if err != nil {
170		return nil, oops.Wrapf(err, "can't read group")
171	}
172
173	goodGroups := make([]Group, 0)
174	for _, g := range groups {
175		if g.Name != groupName {
176			if idx := slices.IndexFunc(g.Branches, func(b Branch) bool { return b.Name == groupName }); idx > -1 {
177				goodGroups = append(goodGroups, Group{
178					Name:     g.Name,
179					Users:    g.Users,
180					Branches: append(g.Branches[:idx], g.Branches[idx+1:]...),
181				})
182			} else {
183				goodGroups = append(goodGroups, g)
184			}
185		}
186	}
187
188	return WriteGroups(goodGroups)
189}
190
191func FindUser(file []byte, sshKey string) (*GitRootUser, error) {
192	groups, err := ParseGroups(file)
193	if err != nil {
194		return nil, oops.Wrapf(err, "can't read group")
195	}
196	slices.SortFunc(groups, func(g1, g2 Group) int {
197		return strings.Compare(g1.Name, g2.Name)
198	})
199	found := false
200	var goodUser User
201	goodGroups := []*GitRootGroup{}
202	otherGroups := make([]*GitRootGroup, 0)
203	for _, g := range groups {
204		branches := make([]string, len(g.Branches))
205		for i, b := range g.Branches {
206			branches[i] = b.Name
207		}
208		currentGroup := &GitRootGroup{
209			GroupName: g.Name,
210			Branches:  branches,
211		}
212
213		userInGroup := false
214		for _, u := range g.Users {
215			for _, s := range u.Ssh {
216				if s == sshKey {
217					if !found {
218						found = true
219						goodUser = u
220					}
221					userInGroup = true
222					goodGroups = append(goodGroups, currentGroup)
223				}
224			}
225		}
226
227		if !userInGroup {
228			otherGroups = append(otherGroups, currentGroup)
229		}
230
231	}
232	if !found {
233		return &GitRootUser{
234			Groups:      goodGroups,
235			Email:       "",
236			Pseudo:      "",
237			OtherGroups: otherGroups,
238			PubKey:      sshKey,
239		}, nil
240	}
241	return &GitRootUser{
242		Groups:      goodGroups,
243		Email:       goodUser.Emails[0],
244		Pseudo:      goodUser.Pseudo,
245		OtherGroups: otherGroups,
246		PubKey:      sshKey,
247	}, nil
248}
249
250func (u *GitRootUser) IsAnonymous() bool {
251	return len(u.Groups) == 0
252}
253
254func (u *GitRootUser) NeedToBeCreated(branch string) bool {
255	if !u.CanWrite(branch) {
256		return false
257	}
258
259	for _, g := range u.Groups {
260		for _, b := range g.Branches {
261			if b == branch || b == "*" {
262				return false
263			}
264		}
265	}
266	return true
267}
268
269func (u *GitRootUser) CanWrite(branch string) bool {
270	// user is in the group of the branch with strict name check
271	for _, g := range u.Groups {
272		for _, b := range g.Branches {
273			if b == branch {
274				return true
275			}
276		}
277	}
278	// other group have right for this branch
279	for _, g := range u.OtherGroups {
280		for _, b := range g.Branches {
281			if b == branch || b == "*" {
282				return false
283			}
284		}
285	}
286	// user is in a group with access to all
287	for _, g := range u.Groups {
288		for _, b := range g.Branches {
289			if b == "*" {
290				return true
291			}
292		}
293	}
294	// nobody take care of this branch
295	return true
296}