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