internal/control/profile.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/control/profile.go history · blame · raw

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