internal/control/profile.go

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

503 lines · 16201 bytes

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