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