internal/control/profile.go

aa03e8c8e02357cfe5f94188fdab3c79846cebae
gitbay/internal/control/profile.go history · blame · raw

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