internal/control/profile.go

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