~gitpocket/gitpocket

4

feat(profile): follow organizations and change usernames with redirects

commit
9108144c05c0d4a3db1a367645caaf4b5601b4c1 browse tree
parent
8262d5df5a44e8db4f3d7e18a40f4ccb6fa982a8
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-13 17:41 UTC

9 files changed, +162 −9

forge/admin.go modified

@@ -110,6 +110,12 @@ func (s *Store) AddRedirect(owner, name string, repo int64) {
110110 func (s *Store) Redirect(owner, name string) (Repo, bool) {
111111 var id int64
112112 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+ }
113119 return Repo{}, false
114120 }
115121 repo, err := s.RepoByID(id)

forge/flow_test.go modified

@@ -531,8 +531,17 @@ func TestFollows(t *testing.T) {
531531 if followers, following := s.store.FollowCounts(bob.ID); followers != 1 || following != 0 {
532532 t.Errorf("bob has %d followers, %d following", followers, following)
533533 }
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")
536545 }
537546 if body := get(h, "/~bob/followers", "").Body.String(); !strings.Contains(body, "~alice") {
538547 t.Error("follower not listed")
@@ -548,3 +557,37 @@ func TestFollows(t *testing.T) {
548557 t.Error("anonymous follow stored")
549558 }
550559 }
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) {
4949 if u == nil {
5050 return
5151 }
52- if u.ID != owner.ID && !s.store.IsOrg(owner.ID) {
52+ if u.ID != owner.ID && !s.store.IsOrg(u.ID) {
5353 s.store.Follow(u.ID, owner.ID, !s.store.Follows(u.ID, owner.ID))
5454 }
5555 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
6565 people = append(people, userHit{User: p, Profile: s.store.Profile(p.ID)})
6666 }
6767 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)}})
6969 }

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) {
277277 if _, err := db.Exec(schema); err != nil {
278278 return nil, err
279279 }
280- for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema} {
280+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema} {
281281 if _, err := db.Exec(ddl); err != nil {
282282 return nil, err
283283 }

forge/web.go modified

@@ -286,6 +286,7 @@ func (s *Server) handler() http.Handler {
286286 mux.HandleFunc("POST /settings/tokens", s.addToken)
287287 mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken)
288288 mux.HandleFunc("POST /settings/password", s.password)
289+ mux.HandleFunc("POST /settings/username", s.rename)
289290 mux.HandleFunc("POST /settings/profile", s.profile)
290291 mux.HandleFunc("POST /settings/avatar", s.avatar)
291292 mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar)
@@ -882,6 +883,18 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
882883 }
883884 owner, err := s.store.UserByName(parts[0])
884885 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+ }
885898 s.fail(w, r, 404, "No such user")
886899 return
887900 }
@@ -911,6 +924,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
911924 people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)})
912925 }
913926 data["Members"] = people
927+ data["Followers"], _ = s.store.FollowCounts(owner.ID)
928+ data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID)
914929 s.render(w, r, "org", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: data})
915930 return
916931 }
@@ -944,7 +959,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
944959 s.orgSettings(w, r, owner, name)
945960 return
946961 }
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" {
948963 s.follows(w, r, owner, user, name)
949964 return
950965 }

web/templates/follows.tmpl modified

@@ -2,7 +2,7 @@
22 {{$o := .Data.Owner}}
33 <div class="userhead">
44 <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>
66 </div>
77 {{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>
88 {{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 @@
88 <p class="handle">~{{$o.Name}}</p>
99 {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}}
1010 <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>
1112 {{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}}
1213 {{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}}
1314 <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>
1415 </ul>
1516 </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}}
1718 </header>
1819 <div class="columns orgbody">
1920 <div class="main">

web/templates/settings.tmpl modified

@@ -24,10 +24,18 @@
2424 <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>
2525 <div class="actions left"><button class="btn">Save ▸</button></div>
2626 </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>
2736 <h3 class="rule">Profile</h3>
2837 <form method="post" action="/settings/profile" class="stack wide">
2938 <div class="pair">
30-<label>Username <span class="muted">(you can't edit this)</span><input value="{{.User.Name}}" disabled></label>
3139 <label>Name <input name="display" value="{{$p.Display}}" maxlength="100" placeholder="Shown on your profile"></label>
3240 </div>
3341 <div class="pair">