web: the owner page dispatches profile show !122
10 files changed, +207 −108
Layout: unified · split
e2e/profile_test.go +41 −1
| @@ -1,6 +1,8 @@ | |||
| 1 | package e2e | 1 | package e2e |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 4 | "strings" | 6 | "strings" |
| 5 | "testing" | 7 | "testing" |
| 6 | ) | 8 | ) |
| @@ -46,6 +48,44 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 46 | } | 48 | } |
| 47 | } | 49 | } |
| 48 | 50 | ||
| 51 | // A profile's repository rows carry the listing metadata the web | ||
| 52 | // shows: topics, license, default branch, last commit. Without them | ||
| 53 | // the web had to decorate the rows itself, which is what kept it | ||
| 54 | // reading the store (krz/gitbay#47). | ||
| 55 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/public-tool", "cli", "go"); code != 0 { | ||
| 56 | t.Fatalf("topics add: %s", errOut) | ||
| 57 | } | ||
| 58 | env := inst.gitEnv(aliceKey) | ||
| 59 | work := t.TempDir() | ||
| 60 | mustGit(t, work, env, "clone", inst.sshURL("alice/public-tool"), "w") | ||
| 61 | dir := filepath.Join(work, "w") | ||
| 62 | os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("MIT License\n\nPermission is hereby granted, free of charge\n"), 0o644) | ||
| 63 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 64 | mustGit(t, dir, env, "add", ".") | ||
| 65 | mustGit(t, dir, env, "commit", "-q", "-m", "add license") | ||
| 66 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 67 | |||
| 68 | out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") | ||
| 69 | for _, want := range []string{`"cli"`, `"go"`, `"license":"MIT"`, `"default_branch":"main"`, `"updated"`} { | ||
| 70 | if !strings.Contains(out, want) { | ||
| 71 | t.Errorf("profile repo row missing %q: %s", want, out) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | // The owner page renders those rows, having dispatched the same | ||
| 75 | // command rather than assembling them from the store again. | ||
| 76 | status, body := inst.get(t, "/alice") | ||
| 77 | if status != 200 { | ||
| 78 | t.Fatalf("owner page: %d", status) | ||
| 79 | } | ||
| 80 | for _, want := range []string{">public-tool<", "?q=cli", "MIT", "updated "} { | ||
| 81 | if !strings.Contains(body, want) { | ||
| 82 | t.Errorf("owner page missing %q:\n%s", want, body) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | if strings.Contains(body, "secret") { | ||
| 86 | t.Fatal("owner page leaks a private repo") | ||
| 87 | } | ||
| 88 | |||
| 49 | // A stranger sees the public repository and not the private one. | 89 | // A stranger sees the public repository and not the private one. |
| 50 | out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") | 90 | out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") |
| 51 | if !strings.Contains(out, `"path":"alice/public-tool"`) { | 91 | if !strings.Contains(out, `"path":"alice/public-tool"`) { |
| @@ -123,7 +163,7 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 123 | } | 163 | } |
| 124 | // Org emphasis parsed, not left as literal slashes the way the | 164 | // Org emphasis parsed, not left as literal slashes the way the |
| 125 | // markdown renderer would. | 165 | // markdown renderer would. |
| 126 | status, body := inst.get(t, "/alice") | 166 | _, body = inst.get(t, "/alice") |
| 127 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { | 167 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { |
| 128 | t.Fatalf("org about not rendered as org: %s", body) | 168 | t.Fatalf("org about not rendered as org: %s", body) |
| 129 | } | 169 | } |
internal/control/license.go added +50
| @@ -0,0 +1,50 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | |||
| 6 | "gitbay.org/gitbay/internal/gitutil" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // licenseNames maps content markers to display names, checked in order. | ||
| 10 | var licenseChecks = []struct{ marker, name string }{ | ||
| 11 | {"MIT License", "MIT"}, | ||
| 12 | {"Permission is hereby granted, free of charge", "MIT"}, | ||
| 13 | {"Apache License", "Apache-2.0"}, | ||
| 14 | {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"}, | ||
| 15 | {"GNU GENERAL PUBLIC LICENSE", "GPL"}, | ||
| 16 | {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"}, | ||
| 17 | {"Mozilla Public License", "MPL-2.0"}, | ||
| 18 | {"BSD 3-Clause", "BSD-3-Clause"}, | ||
| 19 | {"BSD 2-Clause", "BSD-2-Clause"}, | ||
| 20 | {"Redistribution and use in source and binary forms", "BSD"}, | ||
| 21 | {"BSD Zero Clause License", "0BSD"}, | ||
| 22 | {"Zero Clause BSD", "0BSD"}, | ||
| 23 | // ISC is the 0BSD grant plus the notice-retention clause; match on the | ||
| 24 | // clause so title-less 0BSD files don't read as ISC. | ||
| 25 | {"hereby granted, provided that the above copyright notice", "ISC"}, | ||
| 26 | {"Permission to use, copy, modify, and/or distribute", "0BSD"}, | ||
| 27 | {"This is free and unencumbered software", "Unlicense"}, | ||
| 28 | {"CC0", "CC0"}, | ||
| 29 | } | ||
| 30 | |||
| 31 | // DetectLicense reports the repo's license name from a conventional file | ||
| 32 | // at the ref root, or "". It lives here because profile show and the | ||
| 33 | // web's repo listings both report it. | ||
| 34 | func DetectLicense(dir, ref string) string { | ||
| 35 | for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} { | ||
| 36 | raw, err := gitutil.ReadBlob(dir, ref, name, 2048) | ||
| 37 | if err != nil { | ||
| 38 | continue | ||
| 39 | } | ||
| 40 | // Collapse whitespace so markers match across line wraps. | ||
| 41 | text := strings.Join(strings.Fields(string(raw)), " ") | ||
| 42 | for _, c := range licenseChecks { | ||
| 43 | if strings.Contains(text, c.marker) { | ||
| 44 | return c.name | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return "license" | ||
| 48 | } | ||
| 49 | return "" | ||
| 50 | } | ||
internal/control/profile.go +25 −8
| @@ -184,11 +184,19 @@ type profileMember struct { | |||
| 184 | Role string `json:"role,omitempty"` | 184 | Role string `json:"role,omitempty"` |
| 185 | } | 185 | } |
| 186 | 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. | ||
| 187 | type profileRepo struct { | 191 | type profileRepo struct { |
| 188 | Path string `json:"path"` | 192 | Path string `json:"path"` |
| 189 | Visibility string `json:"visibility"` | 193 | Visibility string `json:"visibility"` |
| 190 | Description string `json:"description,omitempty"` | 194 | Description string `json:"description,omitempty"` |
| 191 | Archived bool `json:"archived,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"` | ||
| 192 | } | 200 | } |
| 193 | 201 | ||
| 194 | // activityDay is one day's contribution count. Days with nothing are | 202 | // activityDay is one day's contribution count. Days with nothing are |
| @@ -293,11 +301,20 @@ func runProfileShow(c *Ctx, args []string) int { | |||
| 293 | if !policy.CanRead(c.User, repo, grant) { | 301 | if !policy.CanRead(c.User, repo, grant) { |
| 294 | continue | 302 | continue |
| 295 | } | 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 | } | ||
| 296 | d.Repos = append(d.Repos, profileRepo{ | 309 | d.Repos = append(d.Repos, profileRepo{ |
| 297 | Path: repo.Path(), | 310 | Path: repo.Path(), |
| 298 | Visibility: repo.Visibility, | 311 | Visibility: repo.Visibility, |
| 299 | Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)), | 312 | Description: gitutil.ReadDescription(dir), |
| 300 | Archived: repo.Settings.Archived, | 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, | ||
| 301 | }) | 318 | }) |
| 302 | } | 319 | } |
| 303 | 320 | ||
internal/httpd/activity.go +1 −10
| @@ -1,10 +1,6 @@ | |||
| 1 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import "time" |
| 4 | "time" | ||
| 5 | |||
| 6 | "gitbay.org/gitbay/internal/control" | ||
| 7 | ) | ||
| 8 | 4 | ||
| 9 | // activityDay is one cell of the graph; Level buckets Count into the five | 5 | // activityDay is one cell of the graph; Level buckets Count into the five |
| 10 | // intensity classes the stylesheet colors. | 6 | // intensity classes the stylesheet colors. |
| @@ -59,8 +55,3 @@ func activityLevel(n int) int { | |||
| 59 | return 4 | 55 | return 4 |
| 60 | } | 56 | } |
| 61 | } | 57 | } |
| 62 | |||
| 63 | // activitySince is the first day the grid can show, for the query bound. | ||
| 64 | // activitySince is control.ActivityWindow: the profile command and the | ||
| 65 | // web must report the same year, so the span has one definition. | ||
| 66 | func activitySince() string { return control.ActivityWindow() } | ||
internal/httpd/control.go +15 −3
| @@ -70,6 +70,18 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg | |||
| 70 | // target. Read handlers use it so the web renders exactly what the CLI | 70 | // target. Read handlers use it so the web renders exactly what the CLI |
| 71 | // and the API return, rather than reaching past the registry into git. | 71 | // and the API return, rather than reaching past the registry into git. |
| 72 | func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { | 72 | func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { |
| 73 | code, msg := s.dispatchInto(u, argv, target) | ||
| 74 | return msg, code == protocol.ExitOK | ||
| 75 | } | ||
| 76 | |||
| 77 | // runControlIntoCode is runControlInto for handlers that have to tell | ||
| 78 | // "no such thing" from "that failed": a profile page 404s on the first | ||
| 79 | // and errors on the second. | ||
| 80 | func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) { | ||
| 81 | return s.dispatchInto(u, argv, target) | ||
| 82 | } | ||
| 83 | |||
| 84 | func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) { | ||
| 73 | var stdout, stderr bytes.Buffer | 85 | var stdout, stderr bytes.Buffer |
| 74 | ctx := &control.Ctx{ | 86 | ctx := &control.Ctx{ |
| 75 | User: u, | 87 | User: u, |
| @@ -94,14 +106,14 @@ func (s *Server) runControlInto(u store.User, argv []string, target any) (msg st | |||
| 94 | if m == "" { | 106 | if m == "" { |
| 95 | m = strings.TrimSpace(stderr.String()) | 107 | m = strings.TrimSpace(stderr.String()) |
| 96 | } | 108 | } |
| 97 | return m, false | 109 | return code, m |
| 98 | } | 110 | } |
| 99 | if len(env.Data) > 0 { | 111 | if len(env.Data) > 0 { |
| 100 | if err := json.Unmarshal(env.Data, target); err != nil { | 112 | if err := json.Unmarshal(env.Data, target); err != nil { |
| 101 | return "unreadable response", false | 113 | return protocol.ExitFailure, "unreadable response" |
| 102 | } | 114 | } |
| 103 | } | 115 | } |
| 104 | return "", true | 116 | return protocol.ExitOK, "" |
| 105 | } | 117 | } |
| 106 | 118 | ||
| 107 | // runControlJSON runs a command in JSON mode and returns its data object. | 119 | // runControlJSON runs a command in JSON mode and returns its data object. |
internal/httpd/facts.go +2 −1
| @@ -1,6 +1,7 @@ | |||
| 1 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "gitbay.org/gitbay/internal/control" | ||
| 4 | "gitbay.org/gitbay/internal/gitutil" | 5 | "gitbay.org/gitbay/internal/gitutil" |
| 5 | ) | 6 | ) |
| 6 | 7 | ||
| @@ -29,7 +30,7 @@ const maxContributors = 12 | |||
| 29 | func (s *Server) factsFor(p repoPage) repoFacts { | 30 | func (s *Server) factsFor(p repoPage) repoFacts { |
| 30 | f := repoFacts{ | 31 | f := repoFacts{ |
| 31 | Commits: gitutil.CountCommits(p.Dir, p.Ref), | 32 | Commits: gitutil.CountCommits(p.Dir, p.Ref), |
| 32 | License: detectLicense(p.Dir, p.Ref), | 33 | License: control.DetectLicense(p.Dir, p.Ref), |
| 33 | } | 34 | } |
| 34 | if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil { | 35 | if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil { |
| 35 | f.Branches = len(heads) | 36 | f.Branches = len(heads) |
internal/httpd/readme.go −42
| @@ -11,48 +11,6 @@ import ( | |||
| 11 | "gitbay.org/gitbay/internal/gitutil" | 11 | "gitbay.org/gitbay/internal/gitutil" |
| 12 | ) | 12 | ) |
| 13 | 13 | ||
| 14 | // licenseNames maps content markers to display names, checked in order. | ||
| 15 | var licenseChecks = []struct{ marker, name string }{ | ||
| 16 | {"MIT License", "MIT"}, | ||
| 17 | {"Permission is hereby granted, free of charge", "MIT"}, | ||
| 18 | {"Apache License", "Apache-2.0"}, | ||
| 19 | {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"}, | ||
| 20 | {"GNU GENERAL PUBLIC LICENSE", "GPL"}, | ||
| 21 | {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"}, | ||
| 22 | {"Mozilla Public License", "MPL-2.0"}, | ||
| 23 | {"BSD 3-Clause", "BSD-3-Clause"}, | ||
| 24 | {"BSD 2-Clause", "BSD-2-Clause"}, | ||
| 25 | {"Redistribution and use in source and binary forms", "BSD"}, | ||
| 26 | {"BSD Zero Clause License", "0BSD"}, | ||
| 27 | {"Zero Clause BSD", "0BSD"}, | ||
| 28 | // ISC is the 0BSD grant plus the notice-retention clause; match on the | ||
| 29 | // clause so title-less 0BSD files don't read as ISC. | ||
| 30 | {"hereby granted, provided that the above copyright notice", "ISC"}, | ||
| 31 | {"Permission to use, copy, modify, and/or distribute", "0BSD"}, | ||
| 32 | {"This is free and unencumbered software", "Unlicense"}, | ||
| 33 | {"CC0", "CC0"}, | ||
| 34 | } | ||
| 35 | |||
| 36 | // detectLicense reports the repo's license name from a conventional file | ||
| 37 | // at the ref root, or "". | ||
| 38 | func detectLicense(dir, ref string) string { | ||
| 39 | for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} { | ||
| 40 | raw, err := gitutil.ReadBlob(dir, ref, name, 2048) | ||
| 41 | if err != nil { | ||
| 42 | continue | ||
| 43 | } | ||
| 44 | // Collapse whitespace so markers match across line wraps. | ||
| 45 | text := strings.Join(strings.Fields(string(raw)), " ") | ||
| 46 | for _, c := range licenseChecks { | ||
| 47 | if strings.Contains(text, c.marker) { | ||
| 48 | return c.name | ||
| 49 | } | ||
| 50 | } | ||
| 51 | return "license" | ||
| 52 | } | ||
| 53 | return "" | ||
| 54 | } | ||
| 55 | |||
| 56 | // rewriteRelativeLinks makes relative hrefs and srcs in rendered repo | 14 | // rewriteRelativeLinks makes relative hrefs and srcs in rendered repo |
| 57 | // content resolve on the forge: links go to blob pages, images to raw. | 15 | // content resolve on the forge: links go to blob pages, images to raw. |
| 58 | // go-org exports .org links as .html, so an .html target whose .org (or | 16 | // go-org exports .org links as .html, so an .html target whose .org (or |
internal/httpd/web.go +68 −38
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | "path/filepath" | 11 | "path/filepath" |
| 12 | 12 | ||
| 13 | "gitbay.org/gitbay/internal/policy" | 13 | "gitbay.org/gitbay/internal/policy" |
| 14 | "gitbay.org/gitbay/internal/protocol" | ||
| 14 | "html/template" | 15 | "html/template" |
| 15 | "net/http" | 16 | "net/http" |
| 16 | "net/url" | 17 | "net/url" |
| @@ -107,6 +108,10 @@ type describedRepo struct { | |||
| 107 | Updated string | 108 | Updated string |
| 108 | } | 109 | } |
| 109 | 110 | ||
| 111 | // Archived flattens the settings flag so the reporow partial can read the | ||
| 112 | // same field name from a describedRepo and from a profile's repo row. | ||
| 113 | func (d describedRepo) Archived() bool { return d.Settings.Archived } | ||
| 114 | |||
| 110 | func (s *Server) describeAll(repos []store.Repo) []describedRepo { | 115 | func (s *Server) describeAll(repos []store.Repo) []describedRepo { |
| 111 | var out []describedRepo | 116 | var out []describedRepo |
| 112 | for _, r := range repos { | 117 | for _, r := range repos { |
| @@ -114,7 +119,7 @@ func (s *Server) describeAll(repos []store.Repo) []describedRepo { | |||
| 114 | d := describedRepo{ | 119 | d := describedRepo{ |
| 115 | Repo: r, | 120 | Repo: r, |
| 116 | Desc: gitutil.ReadDescription(dir), | 121 | Desc: gitutil.ReadDescription(dir), |
| 117 | License: detectLicense(dir, r.DefaultBranch), | 122 | License: control.DetectLicense(dir, r.DefaultBranch), |
| 118 | Updated: gitutil.LastCommitDate(dir, r.DefaultBranch), | 123 | Updated: gitutil.LastCommitDate(dir, r.DefaultBranch), |
| 119 | } | 124 | } |
| 120 | d.Topics, _ = s.st.ListTopics(r.ID) | 125 | d.Topics, _ = s.st.ListTopics(r.ID) |
| @@ -346,6 +351,47 @@ func crumbs(p repoPage, kind, filePath string) []crumb { | |||
| 346 | return cs | 351 | return cs |
| 347 | } | 352 | } |
| 348 | 353 | ||
| 354 | // profileView is profile show's payload, shaped for the templates. The | ||
| 355 | // repo rows carry the same names the reporow partial reads, so a profile | ||
| 356 | // listing renders identically to explore's. | ||
| 357 | type profileView struct { | ||
| 358 | Name string `json:"name"` | ||
| 359 | Kind string `json:"kind"` | ||
| 360 | Description string `json:"description"` | ||
| 361 | Website string `json:"website"` | ||
| 362 | About string `json:"about"` | ||
| 363 | AboutFormat string `json:"about_format"` | ||
| 364 | Links []store.ProfileLink `json:"links"` | ||
| 365 | Orgs []profileMember `json:"orgs"` | ||
| 366 | Members []profileMember `json:"members"` | ||
| 367 | Repos []profileRepoRow `json:"repos"` | ||
| 368 | Activity []struct { | ||
| 369 | Date string `json:"date"` | ||
| 370 | Count int `json:"count"` | ||
| 371 | } `json:"activity"` | ||
| 372 | } | ||
| 373 | |||
| 374 | type profileMember struct { | ||
| 375 | Name string `json:"name"` | ||
| 376 | Role string `json:"role"` | ||
| 377 | } | ||
| 378 | |||
| 379 | // profileRepoRow is one repository row on a profile. Path arrives as | ||
| 380 | // owner/name; OwnerName and Name are split out for the partial. | ||
| 381 | type profileRepoRow struct { | ||
| 382 | Path string `json:"path"` | ||
| 383 | Visibility string `json:"visibility"` | ||
| 384 | Desc string `json:"description"` | ||
| 385 | DefaultBranch string `json:"default_branch"` | ||
| 386 | Topics []string `json:"topics"` | ||
| 387 | License string `json:"license"` | ||
| 388 | Updated string `json:"updated"` | ||
| 389 | Archived bool `json:"archived"` | ||
| 390 | } | ||
| 391 | |||
| 392 | func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner } | ||
| 393 | func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name } | ||
| 394 | |||
| 349 | // ownerPage renders /{owner} for users and orgs: the repositories the | 395 | // ownerPage renders /{owner} for users and orgs: the repositories the |
| 350 | // viewer may see, org membership either direction. Owner names are not | 396 | // viewer may see, org membership either direction. Owner names are not |
| 351 | // secret (they are on every commit); repository visibility rules hold. | 397 | // secret (they are on every commit); repository visibility rules hold. |
| @@ -356,62 +402,46 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |||
| 356 | viewer = s.viewer(r) | 402 | viewer = s.viewer(r) |
| 357 | } | 403 | } |
| 358 | 404 | ||
| 359 | kind := "user" | 405 | // Everything on this page — membership, the repositories this viewer |
| 360 | var ownerID int64 | 406 | // may see, the activity year — comes from profile show, so the page |
| 361 | var members []store.OrgMember | 407 | // and the command cannot report different things. |
| 362 | var orgs []store.OrgMember | 408 | var d profileView |
| 363 | if u, err := s.st.UserByUsername(name); err == nil { | 409 | code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d) |
| 364 | ownerID = u.ID | 410 | switch { |
| 365 | orgs, _ = s.st.ListOrgsForUser(u.ID) | 411 | case code == protocol.ExitNotFound: |
| 366 | } else if o, err := s.st.OrgByName(name); err == nil { | ||
| 367 | kind, ownerID = "org", o.ID | ||
| 368 | members, _ = s.st.OrgMembers(o.ID) | ||
| 369 | } else { | ||
| 370 | s.notFound(w, r) | 412 | s.notFound(w, r) |
| 371 | return | 413 | return |
| 372 | } | 414 | case code != protocol.ExitOK: |
| 373 | profile, _ := s.st.OwnerProfile(kind, ownerID) | 415 | log.Printf("profile %s: %s", name, msg) |
| 374 | |||
| 375 | all, err := s.st.ListReposForOwner(kind, ownerID) | ||
| 376 | if err != nil { | ||
| 377 | http.Error(w, "internal error", http.StatusInternalServerError) | 416 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 378 | return | 417 | return |
| 379 | } | 418 | } |
| 380 | var visible []store.Repo | 419 | |
| 381 | for _, repo := range all { | 420 | counts := make(map[string]int, len(d.Activity)) |
| 382 | grant := "" | 421 | for _, day := range d.Activity { |
| 383 | if viewer.ID != 0 { | 422 | counts[day.Date] = day.Count |
| 384 | grant, _ = s.st.AccessRole(repo.ID, viewer.ID) | ||
| 385 | } | ||
| 386 | if policy.CanRead(viewer, repo, grant) { | ||
| 387 | visible = append(visible, repo) | ||
| 388 | } | ||
| 389 | } | ||
| 390 | var counts map[string]int | ||
| 391 | if kind == "user" { | ||
| 392 | counts, _ = s.st.ActivityByDay(ownerID, activitySince()) | ||
| 393 | } else { | ||
| 394 | counts, _ = s.st.OrgActivityByDay(ownerID, activitySince()) | ||
| 395 | } | 423 | } |
| 396 | weeks, activityTotal := activityGrid(counts) | 424 | weeks, activityTotal := activityGrid(counts) |
| 397 | 425 | ||
| 398 | teams, canAdmin := s.orgAdminView(viewer, kind, name) | 426 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) |
| 427 | profile := store.Profile{Description: d.Description, Website: d.Website, | ||
| 428 | About: d.About, AboutFormat: d.AboutFormat, Links: d.Links} | ||
| 399 | s.render(w, "owner.html", struct { | 429 | s.render(w, "owner.html", struct { |
| 400 | basePage | 430 | basePage |
| 401 | Owner string | 431 | Owner string |
| 402 | Kind string | 432 | Kind string |
| 403 | Profile store.Profile | 433 | Profile store.Profile |
| 404 | AboutHTML template.HTML | 434 | AboutHTML template.HTML |
| 405 | Repos []describedRepo | 435 | Repos []profileRepoRow |
| 406 | Members []store.OrgMember | 436 | Members []profileMember |
| 407 | Orgs []store.OrgMember | 437 | Orgs []profileMember |
| 408 | Activity []activityWeek | 438 | Activity []activityWeek |
| 409 | ActivityTotal int | 439 | ActivityTotal int |
| 410 | Teams []teamView | 440 | Teams []teamView |
| 411 | CanAdmin bool | 441 | CanAdmin bool |
| 412 | Notice string | 442 | Notice string |
| 413 | }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile), | 443 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), |
| 414 | s.describeAll(visible), members, orgs, | 444 | d.Repos, d.Members, d.Orgs, |
| 415 | weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) | 445 | weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) |
| 416 | } | 446 | } |
| 417 | 447 | ||
internal/web/templates/layout.html +1 −1
| @@ -93,7 +93,7 @@ | |||
| 93 | {{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} | 93 | {{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} |
| 94 | 94 | ||
| 95 | {{define "reporow"}}<li> | 95 | {{define "reporow"}}<li> |
| 96 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Settings.Archived}} <span class="chip">Archived</span>{{end}}</p> | 96 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p> |
| 97 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} | 97 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} |
| 98 | {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} | 98 | {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} |
| 99 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> | 99 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> |
internal/web/templates/owner.html +4 −4
| @@ -5,8 +5,8 @@ | |||
| 5 | {{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} | 5 | {{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} |
| 6 | {{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} | 6 | {{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} |
| 7 | {{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}} | 7 | {{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}} |
| 8 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} | 8 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Name}}">{{.Name}}</a> {{end}}</p>{{end}} |
| 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} | 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} |
| 10 | </section> | 10 | </section> |
| 11 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} | 11 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} |
| 12 | <section class="activity"> | 12 | <section class="activity"> |
| @@ -29,9 +29,9 @@ | |||
| 29 | <div class="tablewrap"><table class="keys"> | 29 | <div class="tablewrap"><table class="keys"> |
| 30 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> | 30 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> |
| 31 | {{range .Members}}<tr> | 31 | {{range .Members}}<tr> |
| 32 | <td><a href="/{{.Username}}">{{.Username}}</a></td> | 32 | <td><a href="/{{.Name}}">{{.Name}}</a></td> |
| 33 | <td>{{.Role}}</td> | 33 | <td>{{.Role}}</td> |
| 34 | <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Username}}"><button type="submit" class="linklike">Remove</button></form></td> | 34 | <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Name}}"><button type="submit" class="linklike">Remove</button></form></td> |
| 35 | </tr> | 35 | </tr> |
| 36 | {{end}}</table></div> | 36 | {{end}}</table></div> |
| 37 | <details class="editbox"> | 37 | <details class="editbox"> |