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}