internal/control/profile.go
469 lines · 14734 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]",
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.emit(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 })
301}
302
303func runProfileShow(c *Ctx, args []string) int {
304 name := c.User.Username
305 if len(args) == 1 {
306 name = args[0]
307 } else if len(args) > 1 {
308 return c.usage()
309 }
310 kind, id := "", int64(0)
311 if u, err := c.Store.UserByUsername(name); err == nil {
312 kind, id = "user", u.ID
313 } else if o, err := c.Store.OrgByName(name); err == nil {
314 kind, id = "org", o.ID
315 } else {
316 return c.fail(protocol.ExitNotFound, "no user or organization %q", name)
317 }
318 p, err := c.Store.OwnerProfile(kind, id)
319 if err != nil {
320 return c.fail(protocol.ExitFailure, "%v", err)
321 }
322 about, aboutFormat, aboutPath := ownerAbout(c, name)
323 d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
324 About: about, AboutFormat: aboutFormat, AboutPath: aboutPath,
325 Links: p.Links, Repos: []ProfileRepo{}}
326
327 // Who they work with. Both lists are public on a profile — the web
328 // has always shown them — and neither exposes anything a member
329 // listing would not.
330 if kind == "user" {
331 orgs, err := c.Store.ListOrgsForUser(id)
332 if err != nil {
333 return c.fail(protocol.ExitFailure, "%v", err)
334 }
335 for _, o := range orgs {
336 d.Orgs = append(d.Orgs, ProfileMember{Name: o.Username, Role: o.Role})
337 }
338 } else {
339 members, err := c.Store.OrgMembers(id)
340 if err != nil {
341 return c.fail(protocol.ExitFailure, "%v", err)
342 }
343 for _, m := range members {
344 d.Members = append(d.Members, ProfileMember{Name: m.Username, Role: m.Role})
345 }
346 }
347
348 // Only repositories this caller may read: a private repo must not
349 // surface on a profile any more than it does in a listing.
350 all, err := c.Store.ListReposForOwner(kind, id)
351 if err != nil {
352 return c.fail(protocol.ExitFailure, "%v", err)
353 }
354 for _, repo := range all {
355 // A dot-repo is infrastructure, not a project: .gitbay holds this
356 // profile's about text and does not belong in its listing.
357 if strings.HasPrefix(repo.Name, ".") {
358 continue
359 }
360 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
361 if err != nil {
362 return c.fail(protocol.ExitFailure, "%v", err)
363 }
364 if !policy.CanRead(c.User, repo, grant) {
365 continue
366 }
367 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
368 topics, err := c.Store.ListTopics(repo.ID)
369 if err != nil {
370 return c.fail(protocol.ExitFailure, "%v", err)
371 }
372 d.Repos = append(d.Repos, ProfileRepo{
373 Path: repo.Path(),
374 Visibility: repo.Visibility,
375 Description: gitutil.ReadDescription(dir),
376 DefaultBranch: repo.DefaultBranch,
377 Topics: topics,
378 License: DetectLicense(dir, repo.DefaultBranch),
379 Updated: gitutil.LastCommitDate(dir, repo.DefaultBranch),
380 Archived: repo.Settings.Archived,
381 })
382 }
383
384 if kind == "user" {
385 seeAll := id == c.User.ID || c.User.IsAdmin
386 if d.Snippets, err = c.Store.CountSnippets(id, seeAll); err != nil {
387 return c.fail(protocol.ExitFailure, "%v", err)
388 }
389 }
390
391 var counts map[string]int
392 if kind == "user" {
393 counts, err = c.Store.ActivityByDay(id, ActivityWindow())
394 } else {
395 counts, err = c.Store.OrgActivityByDay(id, ActivityWindow())
396 }
397 if err != nil {
398 return c.fail(protocol.ExitFailure, "%v", err)
399 }
400 days := make([]string, 0, len(counts))
401 for day := range counts {
402 days = append(days, day)
403 }
404 sort.Strings(days)
405 for _, day := range days {
406 d.Activity = append(d.Activity, ActivityDay{Date: day, Count: counts[day]})
407 d.ActivityTotal += counts[day]
408 }
409 return emitProfile(c, d)
410}
411
412func runProfileSet(c *Ctx, args []string) int {
413 rest, e, err := parseProfileFlags(args)
414 if err != nil {
415 return c.failInput(err)
416 }
417 if len(rest) != 0 {
418 return c.usage()
419 }
420 if e.empty() {
421 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link")
422 }
423 p, err := c.Store.OwnerProfile("user", c.User.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("user", c.User.ID, p); err != nil {
432 return c.fail(protocol.ExitFailure, "%v", err)
433 }
434 return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user",
435 Description: p.Description, Website: p.Website, Links: p.Links,
436 Repos: []ProfileRepo{}})
437}
438
439func runOrgProfile(c *Ctx, args []string) int {
440 rest, e, err := parseProfileFlags(args)
441 if err != nil {
442 return c.failInput(err)
443 }
444 if len(rest) != 1 {
445 return c.usage()
446 }
447 name := rest[0]
448 if e.empty() {
449 return runProfileShow(c, []string{name})
450 }
451 org, code := orgAdmin(c, name)
452 if code >= 0 {
453 return code
454 }
455 p, err := c.Store.OwnerProfile("org", org.ID)
456 if err != nil {
457 return c.fail(protocol.ExitFailure, "%v", err)
458 }
459 p, err = applyProfile(p, e)
460 if err != nil {
461 return c.failInput(err)
462 }
463 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
464 return c.fail(protocol.ExitFailure, "%v", err)
465 }
466 return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org",
467 Description: p.Description, Website: p.Website, Links: p.Links,
468 Repos: []ProfileRepo{}})
469}