internal/control/profile.go
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}