feat(profile): follow organizations and change usernames with redirects
9 files changed, +162 −9
- forge/admin.go modified +6 −0
- forge/flow_test.go modified +45 −2
- forge/follow.go modified +2 −2
- forge/rename.go added +80 −0
- forge/store.go modified +1 −1
- forge/web.go modified +16 −1
- web/templates/follows.tmpl modified +1 −1
- web/templates/org.tmpl modified +2 −1
- web/templates/settings.tmpl modified +9 −1
forge/admin.go modified
| @@ -110,6 +110,12 @@ func (s *Store) AddRedirect(owner, name string, repo int64) { | ||
| 110 | 110 | func (s *Store) Redirect(owner, name string) (Repo, bool) { |
| 111 | 111 | var id int64 |
| 112 | 112 | if err := s.db.QueryRow(`select repo from redirects where owner = ? and name = ?`, owner, name).Scan(&id); err != nil { |
| 113 | + if u, ok := s.Renamed(owner); ok { | |
| 114 | + if repo, err := s.RepoByPath(u.Name, name); err == nil { | |
| 115 | + return repo, true | |
| 116 | + } | |
| 117 | + return s.Redirect(u.Name, name) | |
| 118 | + } | |
| 113 | 119 | return Repo{}, false |
| 114 | 120 | } |
| 115 | 121 | repo, err := s.RepoByID(id) |
forge/flow_test.go modified
| @@ -531,8 +531,17 @@ func TestFollows(t *testing.T) { | ||
| 531 | 531 | if followers, following := s.store.FollowCounts(bob.ID); followers != 1 || following != 0 { |
| 532 | 532 | t.Errorf("bob has %d followers, %d following", followers, following) |
| 533 | 533 | } |
| 534 | - if s.store.Follows(alice.ID, alice.ID) || s.store.Follows(alice.ID, org.ID) { | |
| 535 | - t.Error("followed self or an organization") | |
| 534 | + if s.store.Follows(alice.ID, alice.ID) { | |
| 535 | + t.Error("followed self") | |
| 536 | + } | |
| 537 | + if !s.store.Follows(alice.ID, org.ID) { | |
| 538 | + t.Error("could not follow an organization") | |
| 539 | + } | |
| 540 | + if body := get(h, "/~crew/followers", "").Body.String(); !strings.Contains(body, "~alice") { | |
| 541 | + t.Error("organization follower not listed") | |
| 542 | + } | |
| 543 | + if get(h, "/~crew/following", "").Code != http.StatusNotFound { | |
| 544 | + t.Error("organization has a following page") | |
| 536 | 545 | } |
| 537 | 546 | if body := get(h, "/~bob/followers", "").Body.String(); !strings.Contains(body, "~alice") { |
| 538 | 547 | t.Error("follower not listed") |
| @@ -548,3 +557,37 @@ func TestFollows(t *testing.T) { | ||
| 548 | 557 | t.Error("anonymous follow stored") |
| 549 | 558 | } |
| 550 | 559 | } |
| 560 | + | |
| 561 | +func TestRename(t *testing.T) { | |
| 562 | + s, h := testServer(t) | |
| 563 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 564 | + s.store.CreateUser("bob", "correct horse") | |
| 565 | + c, _ := s.store.CreateSession(alice.ID) | |
| 566 | + if w := post(h, "/create", url.Values{"name": {"proj"}, "visibility": {"public"}}, c); w.Code != http.StatusSeeOther { | |
| 567 | + t.Fatalf("create gave %d", w.Code) | |
| 568 | + } | |
| 569 | + post(h, "/settings/username", url.Values{"name": {"bob"}, "password": {"correct horse"}}, c) | |
| 570 | + post(h, "/settings/username", url.Values{"name": {"alicia"}, "password": {"wrong"}}, c) | |
| 571 | + if u, _ := s.store.userByID(alice.ID); u.Name != "alice" { | |
| 572 | + t.Fatalf("renamed to %s despite a taken name or a wrong password", u.Name) | |
| 573 | + } | |
| 574 | + if w := post(h, "/settings/username", url.Values{"name": {"alicia"}, "password": {"correct horse"}}, c); w.Code != http.StatusSeeOther { | |
| 575 | + t.Fatalf("rename gave %d", w.Code) | |
| 576 | + } | |
| 577 | + if get(h, "/~alicia/proj", "").Code != http.StatusOK { | |
| 578 | + t.Error("repository not served under the new name") | |
| 579 | + } | |
| 580 | + if w := get(h, "/~alice/proj/log", ""); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/~alicia/proj/log" { | |
| 581 | + t.Errorf("old path gave %d to %q", w.Code, w.Header().Get("Location")) | |
| 582 | + } | |
| 583 | + if repo, ok := s.store.Redirect("alice", "proj"); !ok || repo.Owner.Name != "alicia" { | |
| 584 | + t.Error("clone path under the old name does not resolve") | |
| 585 | + } | |
| 586 | + s.store.CreateUser("alice", "correct horse") | |
| 587 | + if get(h, "/~alice", "").Code != http.StatusOK { | |
| 588 | + t.Error("new owner of the old name is still redirected") | |
| 589 | + } | |
| 590 | + if _, ok := s.store.Redirect("alice", "proj"); ok { | |
| 591 | + t.Error("old repository path still resolves after the name was taken") | |
| 592 | + } | |
| 593 | +} | |
forge/follow.go modified
| @@ -49,7 +49,7 @@ func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) { | ||
| 49 | 49 | if u == nil { |
| 50 | 50 | return |
| 51 | 51 | } |
| 52 | - if u.ID != owner.ID && !s.store.IsOrg(owner.ID) { | |
| 52 | + if u.ID != owner.ID && !s.store.IsOrg(u.ID) { | |
| 53 | 53 | s.store.Follow(u.ID, owner.ID, !s.store.Follows(u.ID, owner.ID)) |
| 54 | 54 | } |
| 55 | 55 | http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther) |
| @@ -65,5 +65,5 @@ func (s *Server) follows(w http.ResponseWriter, r *http.Request, owner User, vie | ||
| 65 | 65 | people = append(people, userHit{User: p, Profile: s.store.Profile(p.ID)}) |
| 66 | 66 | } |
| 67 | 67 | followers, following := s.store.FollowCounts(owner.ID) |
| 68 | - s.render(w, r, "follows", page{Title: "~" + owner.Name + " " + kind, User: viewer, Data: map[string]any{"Owner": owner, "Kind": kind, "People": people, "Followers": followers, "Following": following}}) | |
| 68 | + s.render(w, r, "follows", page{Title: "~" + owner.Name + " " + kind, User: viewer, Data: map[string]any{"Owner": owner, "Kind": kind, "People": people, "Followers": followers, "Following": following, "Org": s.store.IsOrg(owner.ID)}}) | |
| 69 | 69 | } |
forge/rename.go added
| @@ -0,0 +1,80 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "html/template" | |
| 6 | + "net/http" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | +) | |
| 11 | + | |
| 12 | +const renameSchema = ` | |
| 13 | +create table if not exists renames ( | |
| 14 | + old text primary key, | |
| 15 | + user integer not null references users(id) on delete cascade | |
| 16 | +); | |
| 17 | +` | |
| 18 | + | |
| 19 | +func (s *Store) RenameUser(id int64, old, name string) error { | |
| 20 | + if !validName(name) { | |
| 21 | + return errors.New("Names are lowercase letters, digits, dot, dash or underscore, at most 39 characters") | |
| 22 | + } | |
| 23 | + tx, err := s.db.Begin() | |
| 24 | + if err != nil { | |
| 25 | + return err | |
| 26 | + } | |
| 27 | + defer tx.Rollback() | |
| 28 | + if _, err := tx.Exec(`update users set name = ? where id = ?`, name, id); err != nil { | |
| 29 | + if strings.Contains(err.Error(), "UNIQUE") { | |
| 30 | + return errors.New("That name is taken") | |
| 31 | + } | |
| 32 | + return err | |
| 33 | + } | |
| 34 | + tx.Exec(`delete from renames where old = ?`, name) | |
| 35 | + if _, err := tx.Exec(`insert into renames(old, user) values (?, ?) on conflict(old) do update set user = excluded.user`, old, id); err != nil { | |
| 36 | + return err | |
| 37 | + } | |
| 38 | + return tx.Commit() | |
| 39 | +} | |
| 40 | + | |
| 41 | +func (s *Store) Renamed(old string) (User, bool) { | |
| 42 | + var id int64 | |
| 43 | + if s.db.QueryRow(`select r.user from renames r where r.old = ? and not exists (select 1 from users where name = r.old)`, old).Scan(&id) != nil { | |
| 44 | + return User{}, false | |
| 45 | + } | |
| 46 | + u, err := s.userByID(id) | |
| 47 | + return u, err == nil | |
| 48 | +} | |
| 49 | + | |
| 50 | +func (s *Server) rename(w http.ResponseWriter, r *http.Request) { | |
| 51 | + u := s.require(w, r) | |
| 52 | + if u == nil { | |
| 53 | + return | |
| 54 | + } | |
| 55 | + fail := func(msg string) { | |
| 56 | + http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(msg)+"#username", http.StatusSeeOther) | |
| 57 | + } | |
| 58 | + name := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~"))) | |
| 59 | + if name == u.Name { | |
| 60 | + http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 61 | + return | |
| 62 | + } | |
| 63 | + if _, ok := s.store.CheckPassword(u.Name, r.FormValue("password")); !ok { | |
| 64 | + s.limiter.fail(clientIP(r)) | |
| 65 | + fail("Password is wrong") | |
| 66 | + return | |
| 67 | + } | |
| 68 | + if err := s.store.RenameUser(u.ID, u.Name, name); err != nil { | |
| 69 | + fail(err.Error()) | |
| 70 | + return | |
| 71 | + } | |
| 72 | + from, to := filepath.Join(s.root, "repos", u.Name), filepath.Join(s.root, "repos", name) | |
| 73 | + os.Remove(to) | |
| 74 | + if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) { | |
| 75 | + s.store.RenameUser(u.ID, name, u.Name) | |
| 76 | + fail("Could not move your repositories") | |
| 77 | + return | |
| 78 | + } | |
| 79 | + http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 80 | +} | |
forge/store.go modified
| @@ -277,7 +277,7 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 277 | 277 | if _, err := db.Exec(schema); err != nil { |
| 278 | 278 | return nil, err |
| 279 | 279 | } |
| 280 | - for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema} { | |
| 280 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema} { | |
| 281 | 281 | if _, err := db.Exec(ddl); err != nil { |
| 282 | 282 | return nil, err |
| 283 | 283 | } |
forge/web.go modified
| @@ -286,6 +286,7 @@ func (s *Server) handler() http.Handler { | ||
| 286 | 286 | mux.HandleFunc("POST /settings/tokens", s.addToken) |
| 287 | 287 | mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken) |
| 288 | 288 | mux.HandleFunc("POST /settings/password", s.password) |
| 289 | + mux.HandleFunc("POST /settings/username", s.rename) | |
| 289 | 290 | mux.HandleFunc("POST /settings/profile", s.profile) |
| 290 | 291 | mux.HandleFunc("POST /settings/avatar", s.avatar) |
| 291 | 292 | mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar) |
| @@ -882,6 +883,18 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 882 | 883 | } |
| 883 | 884 | owner, err := s.store.UserByName(parts[0]) |
| 884 | 885 | if err != nil { |
| 886 | + if renamed, ok := s.store.Renamed(parts[0]); ok { | |
| 887 | + target := "/~" + renamed.Name + strings.TrimPrefix(r.URL.Path, "/~"+parts[0]) | |
| 888 | + if r.URL.RawQuery != "" { | |
| 889 | + target += "?" + r.URL.RawQuery | |
| 890 | + } | |
| 891 | + code := http.StatusMovedPermanently | |
| 892 | + if r.Method != "GET" && r.Method != "HEAD" { | |
| 893 | + code = http.StatusPermanentRedirect | |
| 894 | + } | |
| 895 | + http.Redirect(w, r, target, code) | |
| 896 | + return | |
| 897 | + } | |
| 885 | 898 | s.fail(w, r, 404, "No such user") |
| 886 | 899 | return |
| 887 | 900 | } |
| @@ -911,6 +924,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 911 | 924 | people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)}) |
| 912 | 925 | } |
| 913 | 926 | data["Members"] = people |
| 927 | + data["Followers"], _ = s.store.FollowCounts(owner.ID) | |
| 928 | + data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID) | |
| 914 | 929 | s.render(w, r, "org", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: data}) |
| 915 | 930 | return |
| 916 | 931 | } |
| @@ -944,7 +959,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 944 | 959 | s.orgSettings(w, r, owner, name) |
| 945 | 960 | return |
| 946 | 961 | } |
| 947 | - if (name == "followers" || name == "following") && len(parts) == 2 && r.Method == "GET" && !s.store.IsOrg(owner.ID) { | |
| 962 | + if (name == "followers" || name == "following" && !s.store.IsOrg(owner.ID)) && len(parts) == 2 && r.Method == "GET" { | |
| 948 | 963 | s.follows(w, r, owner, user, name) |
| 949 | 964 | return |
| 950 | 965 | } |
web/templates/follows.tmpl modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$o := .Data.Owner}} |
| 3 | 3 | <div class="userhead"> |
| 4 | 4 | <h2>~{{$o.Name}}</h2> |
| 5 | -<nav class="tabs small"><a href="/~{{$o.Name}}">about</a><a href="/~{{$o.Name}}/repos">repos</a><a href="/~{{$o.Name}}/stars">stars</a><a href="/~{{$o.Name}}/followers"{{if eq .Data.Kind "followers"}} class="active"{{end}}>followers <span class="count">{{.Data.Followers}}</span></a><a href="/~{{$o.Name}}/following"{{if eq .Data.Kind "following"}} class="active"{{end}}>following <span class="count">{{.Data.Following}}</span></a></nav> | |
| 5 | +<nav class="tabs small"><a href="/~{{$o.Name}}">about</a><a href="/~{{$o.Name}}/repos">repos</a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/stars">stars</a>{{end}}<a href="/~{{$o.Name}}/followers"{{if eq .Data.Kind "followers"}} class="active"{{end}}>followers <span class="count">{{.Data.Followers}}</span></a>{{if not .Data.Org}}<a href="/~{{$o.Name}}/following"{{if eq .Data.Kind "following"}} class="active"{{end}}>following <span class="count">{{.Data.Following}}</span></a>{{end}}</nav> | |
| 6 | 6 | </div> |
| 7 | 7 | {{range .Data.People}}<div class="hit user"><a href="/~{{.Name}}" class="hitavatar"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>Joined {{.Created.Format "January 2006"}}</span></p></div></div> |
| 8 | 8 | {{else}}<p class="muted">{{if eq .Data.Kind "followers"}}Nobody follows ~{{$o.Name}} yet.{{else}}~{{$o.Name}} does not follow anyone yet.{{end}}</p>{{end}} |
web/templates/org.tmpl modified
| @@ -8,12 +8,13 @@ | ||
| 8 | 8 | <p class="handle">~{{$o.Name}}</p> |
| 9 | 9 | {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}} |
| 10 | 10 | <ul class="facts"> |
| 11 | +<li class="followcount"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5.5" r="2.4"/><path d="M1.8 13.5c.5-2.4 2.2-3.7 4.2-3.7s3.7 1.3 4.2 3.7"/><path d="M10.4 3.3a2.4 2.4 0 0 1 0 4.4M12 9.9c1.2.5 2 1.7 2.2 3.6"/></svg><a href="/~{{$o.Name}}/followers">{{.Data.Followers}} follower{{if ne .Data.Followers 1}}s{{end}}</a></li> | |
| 11 | 12 | {{if $p.Location}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 15s5-4.6 5-8.5A5 5 0 0 0 3 6.5C3 10.4 8 15 8 15z"/><circle cx="8" cy="6.5" r="1.8"/></svg>{{$p.Location}}</li>{{end}} |
| 12 | 13 | {{range $p.Links}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.8 9.2a3 3 0 0 0 4.2 0l2.2-2.2a3 3 0 0 0-4.2-4.2l-.9.9"/><path d="M9.2 6.8a3 3 0 0 0-4.2 0L2.8 9a3 3 0 0 0 4.2 4.2l.9-.9"/></svg><a href="{{.}}" rel="nofollow me">{{bare .}}</a></li>{{end}} |
| 13 | 14 | <li title="{{date $o.Created}}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"/></svg>Since {{$o.Created.Format "January 2006"}}</li> |
| 14 | 15 | </ul> |
| 15 | 16 | </div> |
| 16 | -{{if .Data.Member}}<div class="orgactions"><a class="btn" href="/~{{$o.Name}}/settings">Edit organization</a></div>{{end}} | |
| 17 | +{{if .User}}<div class="orgactions"><form method="post" action="/~{{$o.Name}}/follow"><button class="btn{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form>{{if .Data.Member}}<a class="btn" href="/~{{$o.Name}}/settings">Edit organization</a>{{end}}</div>{{end}} | |
| 17 | 18 | </header> |
| 18 | 19 | <div class="columns orgbody"> |
| 19 | 20 | <div class="main"> |
web/templates/settings.tmpl modified
| @@ -24,10 +24,18 @@ | ||
| 24 | 24 | <label class="check"><input type="checkbox" name="notify"{{if .Data.Account.Notify}} checked{{end}}> Email me about pull requests, reviews and issues on my repositories</label> |
| 25 | 25 | <div class="actions left"><button class="btn">Save ▸</button></div> |
| 26 | 26 | </form> |
| 27 | +<h3 class="rule" id="username">Username</h3> | |
| 28 | +<form method="post" action="/settings/username" class="stack wide"> | |
| 29 | +<div class="pair"> | |
| 30 | +<label>New username <input name="name" value="{{.User.Name}}" required maxlength="39" pattern="[a-z0-9._\-]+" autocapitalize="none" spellcheck="false"></label> | |
| 31 | +<label>Current password <input name="password" type="password" required autocomplete="current-password"></label> | |
| 32 | +</div> | |
| 33 | +<small>Links to ~{{.User.Name}} and its repositories, including clone URLs, keep redirecting to the new name until someone else registers ~{{.User.Name}}. Update your git remotes when you can.</small> | |
| 34 | +<div class="actions left"><button class="btn">Change username ▸</button></div> | |
| 35 | +</form> | |
| 27 | 36 | <h3 class="rule">Profile</h3> |
| 28 | 37 | <form method="post" action="/settings/profile" class="stack wide"> |
| 29 | 38 | <div class="pair"> |
| 30 | -<label>Username <span class="muted">(you can't edit this)</span><input value="{{.User.Name}}" disabled></label> | |
| 31 | 39 | <label>Name <input name="display" value="{{$p.Display}}" maxlength="100" placeholder="Shown on your profile"></label> |
| 32 | 40 | </div> |
| 33 | 41 | <div class="pair"> |