internal/control/profile.go

3af6fd1aaf69413d258a4131a627d048d2f1d290
gitbay/internal/control/profile.go history · blame · raw

404 lines · 12665 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"sort"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/gitutil"
 12	"gitbay.org/gitbay/internal/policy"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17func init() {
 18	register(Command{Path: []string{"profile", "show"},
 19		Summary: "show a user's or org's profile",
 20		Usage:   "profile show [name]", ReadOnly: true, Run: runProfileShow})
 21	register(Command{Path: []string{"profile", "set"},
 22		Summary: "set your profile",
 23		Usage:   "profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]... ('' clears)", ReadsStdin: true, Run: runProfileSet})
 24	register(Command{Path: []string{"org", "profile"},
 25		Summary: "show or set an org's profile",
 26		Usage:   "org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...", ReadsStdin: true, Run: runOrgProfile})
 27}
 28
 29// maxProfileLinks caps the free-form link list. A profile is a header,
 30// not a linktree.
 31const maxProfileLinks = 5
 32
 33// profileEdit is the set of profile fields a command may change. A nil
 34// field is left alone; an empty value clears it.
 35type profileEdit struct {
 36	Description *string
 37	Website     *string
 38	About       *string
 39	AboutFormat *string
 40	Links       *[]store.ProfileLink
 41}
 42
 43func (e profileEdit) empty() bool {
 44	return e.Description == nil && e.Website == nil && e.About == nil &&
 45		e.AboutFormat == nil && e.Links == nil
 46}
 47
 48// parseProfileFlags pulls the profile flags out of args. --about takes
 49// inline text or reads stdin via --file -; --link repeats, and a single
 50// empty --link clears the list.
 51func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) {
 52	about, file := "", ""
 53	sawAbout := false
 54	var links []store.ProfileLink
 55	for i := 0; i < len(args); i++ {
 56		switch args[i] {
 57		case "--description", "--website", "--about", "--about-format", "--file", "--link":
 58			if i+1 >= len(args) {
 59				return nil, e, fmt.Errorf("%s requires a value", args[i])
 60			}
 61			v := args[i+1]
 62			switch args[i] {
 63			case "--description":
 64				e.Description = &v
 65			case "--website":
 66				e.Website = &v
 67			case "--about":
 68				about, sawAbout = v, true
 69			case "--about-format":
 70				e.AboutFormat = &v
 71			case "--file":
 72				file, sawAbout = v, true
 73			case "--link":
 74				if v == "" {
 75					links = nil
 76					e.Links = &links
 77					break
 78				}
 79				l, lerr := parseProfileLink(v)
 80				if lerr != nil {
 81					return nil, e, lerr
 82				}
 83				links = append(links, l)
 84				e.Links = &links
 85			}
 86			i++
 87		default:
 88			rest = append(rest, args[i])
 89		}
 90	}
 91	if sawAbout {
 92		body, berr := bodyFrom(c, about, file)
 93		if berr != nil {
 94			return nil, e, berr
 95		}
 96		e.About = &body
 97	}
 98	if len(links) > maxProfileLinks {
 99		return nil, e, fmt.Errorf("at most %d links", maxProfileLinks)
100	}
101	return rest, e, nil
102}
103
104// parseProfileLink splits "label|url"; without a separator the whole
105// value is the URL.
106func parseProfileLink(v string) (store.ProfileLink, error) {
107	label, url, ok := strings.Cut(v, "|")
108	if !ok {
109		label, url = "", v
110	}
111	label = strings.TrimSpace(label)
112	if len(label) > 32 {
113		label = label[:32]
114	}
115	url = strings.TrimSpace(url)
116	if url == "" {
117		return store.ProfileLink{}, errors.New("a link needs a url")
118	}
119	if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
120		return store.ProfileLink{}, errors.New("link url must start with https:// or http://")
121	}
122	return store.ProfileLink{Label: label, URL: url}, nil
123}
124
125func validateWebsite(url string) error {
126	if url == "" || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://") {
127		return nil
128	}
129	return errors.New("website must start with https:// or http://")
130}
131
132func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) {
133	if e.Description != nil {
134		d, _, _ := strings.Cut(strings.TrimSpace(*e.Description), "\n")
135		if len(d) > 256 {
136			d = d[:256]
137		}
138		p.Description = d
139	}
140	if e.Website != nil {
141		s := strings.TrimSpace(*e.Website)
142		if err := validateWebsite(s); err != nil {
143			return p, err
144		}
145		p.Website = s
146	}
147	if e.About != nil {
148		p.About = strings.TrimSpace(*e.About)
149	}
150	if e.AboutFormat != nil {
151		f := strings.TrimSpace(*e.AboutFormat)
152		if f != "md" && f != "org" {
153			return p, errors.New("about format must be md or org")
154		}
155		p.AboutFormat = f
156	}
157	if e.Links != nil {
158		p.Links = *e.Links
159	}
160	return p, nil
161}
162
163type profileOut struct {
164	Name        string `json:"name"`
165	Kind        string `json:"kind"`
166	Description string `json:"description,omitempty"`
167	Website     string `json:"website,omitempty"`
168	// About is long-form markdown, rendered by the web between the
169	// header and the activity graph.
170	About       string              `json:"about,omitempty"`
171	AboutFormat string              `json:"about_format,omitempty"`
172	Links       []store.ProfileLink `json:"links,omitempty"`
173	// The rest is what a profile page shows: who they work with, what
174	// they own that you can see, and how active they have been. The web
175	// read these straight out of the store, which kept them off every
176	// other surface.
177	Orgs     []profileMember `json:"orgs,omitempty"`    // for a user
178	Members  []profileMember `json:"members,omitempty"` // for an org
179	Repos    []profileRepo   `json:"repos"`
180	Activity []activityDay   `json:"activity,omitempty"`
181	// ActivityTotal counts the same window the days cover.
182	ActivityTotal int `json:"activity_total"`
183}
184
185type profileMember struct {
186	Name string `json:"name"`
187	Role string `json:"role,omitempty"`
188}
189
190// profileRepo is one repository as a profile lists it. The listing
191// metadata — topics, license, last commit — is here because a profile is
192// a listing: a client that renders repositories without it is showing
193// less than the web does, which is why the web kept its own copy.
194type profileRepo struct {
195	Path          string   `json:"path"`
196	Visibility    string   `json:"visibility"`
197	Description   string   `json:"description,omitempty"`
198	DefaultBranch string   `json:"default_branch"`
199	Topics        []string `json:"topics,omitempty"`
200	License       string   `json:"license,omitempty"`
201	Updated       string   `json:"updated,omitempty"`
202	Archived      bool     `json:"archived,omitempty"`
203}
204
205// activityDay is one day's contribution count. Days with nothing are
206// omitted; a client fills the calendar it wants to draw.
207type activityDay struct {
208	Date  string `json:"date"`
209	Count int    `json:"count"`
210}
211
212// ActivityWindow is the span a profile reports: the start of the web's
213// 53-week calendar, so every surface shows the same year.
214func ActivityWindow() string {
215	today := time.Now().UTC()
216	end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
217	return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
218}
219
220func emitProfile(c *Ctx, d profileOut) int {
221	return c.emit(d, func(w io.Writer) {
222		fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
223		if d.Description != "" {
224			fmt.Fprintf(w, "%s\n", d.Description)
225		}
226		if d.Website != "" {
227			fmt.Fprintf(w, "%s\n", d.Website)
228		}
229		for _, l := range d.Links {
230			fmt.Fprintf(w, "link\t%s\t%s\n", l.Label, l.URL)
231		}
232		for _, m := range d.Orgs {
233			fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
234		}
235		for _, m := range d.Members {
236			fmt.Fprintf(w, "member\t%s\t%s\n", m.Name, m.Role)
237		}
238		for _, r := range d.Repos {
239			fmt.Fprintf(w, "repo\t%s\t%s\t%s\n", r.Path, r.Visibility, r.Description)
240		}
241		if d.ActivityTotal > 0 {
242			fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
243		}
244		if d.About != "" {
245			fmt.Fprintf(w, "\n%s\n", d.About)
246		}
247	})
248}
249
250func runProfileShow(c *Ctx, args []string) int {
251	name := c.User.Username
252	if len(args) == 1 {
253		name = args[0]
254	} else if len(args) > 1 {
255		return c.fail(protocol.ExitUsage, "usage: profile show [name]")
256	}
257	kind, id := "", int64(0)
258	if u, err := c.Store.UserByUsername(name); err == nil {
259		kind, id = "user", u.ID
260	} else if o, err := c.Store.OrgByName(name); err == nil {
261		kind, id = "org", o.ID
262	} else {
263		return c.fail(protocol.ExitNotFound, "no user or organization %q", name)
264	}
265	p, err := c.Store.OwnerProfile(kind, id)
266	if err != nil {
267		return c.fail(protocol.ExitFailure, "%v", err)
268	}
269	d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
270		About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}}
271
272	// Who they work with. Both lists are public on a profile — the web
273	// has always shown them — and neither exposes anything a member
274	// listing would not.
275	if kind == "user" {
276		orgs, err := c.Store.ListOrgsForUser(id)
277		if err != nil {
278			return c.fail(protocol.ExitFailure, "%v", err)
279		}
280		for _, o := range orgs {
281			d.Orgs = append(d.Orgs, profileMember{Name: o.Username, Role: o.Role})
282		}
283	} else {
284		members, err := c.Store.OrgMembers(id)
285		if err != nil {
286			return c.fail(protocol.ExitFailure, "%v", err)
287		}
288		for _, m := range members {
289			d.Members = append(d.Members, profileMember{Name: m.Username, Role: m.Role})
290		}
291	}
292
293	// Only repositories this caller may read: a private repo must not
294	// surface on a profile any more than it does in a listing.
295	all, err := c.Store.ListReposForOwner(kind, id)
296	if err != nil {
297		return c.fail(protocol.ExitFailure, "%v", err)
298	}
299	for _, repo := range all {
300		grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
301		if err != nil {
302			return c.fail(protocol.ExitFailure, "%v", err)
303		}
304		if !policy.CanRead(c.User, repo, grant) {
305			continue
306		}
307		dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
308		topics, err := c.Store.ListTopics(repo.ID)
309		if err != nil {
310			return c.fail(protocol.ExitFailure, "%v", err)
311		}
312		d.Repos = append(d.Repos, profileRepo{
313			Path:          repo.Path(),
314			Visibility:    repo.Visibility,
315			Description:   gitutil.ReadDescription(dir),
316			DefaultBranch: repo.DefaultBranch,
317			Topics:        topics,
318			License:       DetectLicense(dir, repo.DefaultBranch),
319			Updated:       gitutil.LastCommitDate(dir, repo.DefaultBranch),
320			Archived:      repo.Settings.Archived,
321		})
322	}
323
324	var counts map[string]int
325	if kind == "user" {
326		counts, err = c.Store.ActivityByDay(id, ActivityWindow())
327	} else {
328		counts, err = c.Store.OrgActivityByDay(id, ActivityWindow())
329	}
330	if err != nil {
331		return c.fail(protocol.ExitFailure, "%v", err)
332	}
333	days := make([]string, 0, len(counts))
334	for day := range counts {
335		days = append(days, day)
336	}
337	sort.Strings(days)
338	for _, day := range days {
339		d.Activity = append(d.Activity, activityDay{Date: day, Count: counts[day]})
340		d.ActivityTotal += counts[day]
341	}
342	return emitProfile(c, d)
343}
344
345func runProfileSet(c *Ctx, args []string) int {
346	rest, e, err := parseProfileFlags(c, args)
347	if err != nil {
348		return c.fail(protocol.ExitUsage, "%v", err)
349	}
350	if len(rest) != 0 {
351		return c.fail(protocol.ExitUsage,
352			"usage: profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
353	}
354	if e.empty() {
355		return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link")
356	}
357	p, err := c.Store.OwnerProfile("user", c.User.ID)
358	if err != nil {
359		return c.fail(protocol.ExitFailure, "%v", err)
360	}
361	p, err = applyProfile(p, e)
362	if err != nil {
363		return c.fail(protocol.ExitUsage, "%v", err)
364	}
365	if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
366		return c.fail(protocol.ExitFailure, "%v", err)
367	}
368	return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
369		Description: p.Description, Website: p.Website, About: p.About,
370		AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
371}
372
373func runOrgProfile(c *Ctx, args []string) int {
374	rest, e, err := parseProfileFlags(c, args)
375	if err != nil {
376		return c.fail(protocol.ExitUsage, "%v", err)
377	}
378	if len(rest) != 1 {
379		return c.fail(protocol.ExitUsage,
380			"usage: org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
381	}
382	name := rest[0]
383	if e.empty() {
384		return runProfileShow(c, []string{name})
385	}
386	org, code := orgAdmin(c, name)
387	if code >= 0 {
388		return code
389	}
390	p, err := c.Store.OwnerProfile("org", org.ID)
391	if err != nil {
392		return c.fail(protocol.ExitFailure, "%v", err)
393	}
394	p, err = applyProfile(p, e)
395	if err != nil {
396		return c.fail(protocol.ExitUsage, "%v", err)
397	}
398	if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
399		return c.fail(protocol.ExitFailure, "%v", err)
400	}
401	return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
402		Description: p.Description, Website: p.Website, About: p.About,
403		AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
404}