~gitpocket/gitpocket

feat(basin): mail, password reset, totp, issues, stars, rename, transfer, archive, blame, compare, downloads, feeds, admin commands

commit
a3d84a990a442e524d1adf4fea85a3b7fe99445f browse tree
parent
5b7fbd8a0f64110a74b33e15b31664c736a1ea22
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-12 23:55 UTC

36 files changed, +1502 −34

README.md modified

@@ -13,7 +13,14 @@ Small and lightweight git server for self hosting. Repositories are served over
1313 - Collaborators per repository who can push, read it when private and merge pull requests, while settings and deletion stay with the owner
1414 - A per user disk quota set with `basin quota 2G` and enforced on push over both transports
1515 - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp`
16+- Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip
17+- Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title
18+- Blame, a compare page for any two refs, tar.gz and zip downloads of any ref from `git archive`, and an Atom feed of commits per branch
19+- Rename, transfer to another user and archive from the repository settings. An archived repository refuses pushes, pull requests and comments but stays readable
20+- An optional email address on each account, verified by a link, used for password resets and for notifications about pull requests, reviews and issues on your repositories. Both can be turned off and nothing works without the address, so the whole thing is opt in
21+- TOTP two factor authentication with a QR code for the authenticator app and eight single use recovery codes, which is what a password alone cannot give you on something that accepts pushes
1622 - SSH keys and access tokens live in settings. HTTPS auth is plain basic auth with a token or your password
23+- Relative links and images in a README resolve against the repository, so a `docs/diagram.png` reference renders rather than breaking
1724 - Registration is invite only unless you say otherwise, and there is a failure counter per address covering login and register and password changes and HTTPS auth
1825
1926 ## Design
@@ -74,7 +81,7 @@ To send changes to something you do not own, fork it from the button beside the
7481
7582 ## Limits
7683
77-Raw files stream straight out of `cat-file` at any size, but the rendered view stops at 4 MiB and syntax highlighting at 512 KiB, beyond which a file is shown plain or offered as raw only. The quota is checked before a push starts rather than during it, so a single push can overshoot by its own size. There is no wiki, no issue tracker and no CI, and there are no plans for any of them.
84+Raw files stream straight out of `cat-file` at any size, but the rendered view stops at 4 MiB and syntax highlighting at 512 KiB, beyond which a file is shown plain or offered as raw only. The quota is checked before a push starts rather than during it, so a single push can overshoot by its own size. There is no wiki and no CI, and there are no plans for either.
7885
7986 ## Licence
8087

account.go added

@@ -0,0 +1,208 @@
1+package main
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "html/template"
7+ "net/http"
8+ "net/url"
9+ "strings"
10+
11+ qrcode "github.com/skip2/go-qrcode"
12+)
13+
14+func (s *Server) setEmail(w http.ResponseWriter, r *http.Request) {
15+ u := s.require(w, r)
16+ if u == nil {
17+ return
18+ }
19+ email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
20+ if err := s.store.SetEmail(u.ID, email); err != nil {
21+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
22+ return
23+ }
24+ if email == "" {
25+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
26+ return
27+ }
28+ if s.mailer == nil {
29+ http.Redirect(w, r, "/settings?error=Mail+is+not+configured+on+this+server", http.StatusSeeOther)
30+ return
31+ }
32+ token, err := s.store.CreateMailToken(u.ID, "verify", email)
33+ if err != nil {
34+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
35+ return
36+ }
37+ link := fmt.Sprintf("https://%s/verify/%s", s.host, token)
38+ if err := s.mailer.Send(email, "Verify your email for "+s.host, fmt.Sprintf("Hello ~%s,\n\nOpen this link to confirm your email address:\n\n%s\n\nThe link is valid for 24 hours. If you did not ask for this, ignore it.\n", u.Name, link)); err != nil {
39+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper("Could not send mail: "+err.Error()), http.StatusSeeOther)
40+ return
41+ }
42+ http.Redirect(w, r, "/settings?sent=1", http.StatusSeeOther)
43+}
44+
45+func (s *Server) verifyEmail(w http.ResponseWriter, r *http.Request) {
46+ user, email, ok := s.store.UseMailToken(r.PathValue("token"), "verify")
47+ if !ok {
48+ s.fail(w, r, 400, "That verification link is invalid or has expired")
49+ return
50+ }
51+ s.store.MarkVerified(user, email)
52+ s.render(w, r, "message", page{Title: "Email verified", Data: map[string]any{"Heading": "Email verified", "Text": "Thanks, " + email + " is now confirmed. Password resets and notifications will go there."}})
53+}
54+
55+func (s *Server) setNotify(w http.ResponseWriter, r *http.Request) {
56+ u := s.require(w, r)
57+ if u == nil {
58+ return
59+ }
60+ s.store.SetNotify(u.ID, r.FormValue("notify") == "on")
61+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
62+}
63+
64+func (s *Server) resetForm(w http.ResponseWriter, r *http.Request) {
65+ s.render(w, r, "reset", page{Title: "Reset password", Data: map[string]any{"Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}})
66+}
67+
68+func (s *Server) resetRequest(w http.ResponseWriter, r *http.Request) {
69+ ip := clientIP(r)
70+ if s.limiter.blocked(ip) {
71+ s.fail(w, r, 429, "Too many attempts, try again in a few minutes")
72+ return
73+ }
74+ s.limiter.fail(ip)
75+ who := strings.TrimSpace(r.FormValue("who"))
76+ u, err := s.store.UserByName(who)
77+ if err != nil {
78+ u, err = s.store.UserByEmail(who)
79+ }
80+ if err == nil && s.mailer != nil {
81+ if email := s.store.Account(u.ID).Email; email != "" && s.store.Account(u.ID).Verified {
82+ token, _ := s.store.CreateMailToken(u.ID, "reset", email)
83+ link := fmt.Sprintf("https://%s/reset/%s", s.host, token)
84+ s.mailer.Send(email, "Password reset for "+s.host, fmt.Sprintf("Hello ~%s,\n\nSomeone asked to reset the password for your account. Open this link to choose a new one:\n\n%s\n\nThe link is valid for 24 hours. If that was not you, ignore this message and nothing changes.\n", u.Name, link))
85+ }
86+ }
87+ http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther)
88+}
89+
90+func (s *Server) resetTokenForm(w http.ResponseWriter, r *http.Request) {
91+ s.render(w, r, "resetpassword", page{Title: "Choose a new password", Data: map[string]any{"Token": r.PathValue("token")}})
92+}
93+
94+func (s *Server) resetPassword(w http.ResponseWriter, r *http.Request) {
95+ user, _, ok := s.store.UseMailToken(r.PathValue("token"), "reset")
96+ if !ok {
97+ s.fail(w, r, 400, "That reset link is invalid or has expired")
98+ return
99+ }
100+ if err := s.store.SetPassword(user, r.FormValue("password")); err != nil {
101+ w.WriteHeader(400)
102+ s.render(w, r, "resetpassword", page{Title: "Choose a new password", Error: err.Error(), Data: map[string]any{"Token": r.PathValue("token")}})
103+ return
104+ }
105+ s.store.DeleteSessions(user)
106+ http.Redirect(w, r, "/login", http.StatusSeeOther)
107+}
108+
109+func (s *Server) pendingUser(r *http.Request) (User, bool) {
110+ c, err := r.Cookie("pending")
111+ if err != nil {
112+ return User{}, false
113+ }
114+ var user int64
115+ var created int64
116+ err = s.store.db.QueryRow(`select user, created from mailtokens where token = ? and kind = 'totp'`, c.Value).Scan(&user, &created)
117+ if err != nil {
118+ return User{}, false
119+ }
120+ u, err := s.store.userByID(user)
121+ return u, err == nil
122+}
123+
124+func (s *Server) totpForm(w http.ResponseWriter, r *http.Request) {
125+ if _, ok := s.pendingUser(r); !ok {
126+ http.Redirect(w, r, "/login", http.StatusSeeOther)
127+ return
128+ }
129+ s.render(w, r, "totp", page{Title: "Two factor"})
130+}
131+
132+func (s *Server) totpLogin(w http.ResponseWriter, r *http.Request) {
133+ u, ok := s.pendingUser(r)
134+ if !ok {
135+ http.Redirect(w, r, "/login", http.StatusSeeOther)
136+ return
137+ }
138+ ip := clientIP(r)
139+ if s.limiter.blocked(ip) {
140+ s.fail(w, r, 429, "Too many attempts, try again in a few minutes")
141+ return
142+ }
143+ a := s.store.Account(u.ID)
144+ code := r.FormValue("code")
145+ if !totpValid(a.TOTP, code) {
146+ if left, used := useRecovery(a.Recovery, code); used {
147+ s.store.SetTOTP(u.ID, a.TOTP, left)
148+ } else {
149+ s.limiter.fail(ip)
150+ w.WriteHeader(401)
151+ s.render(w, r, "totp", page{Title: "Two factor", Error: "Wrong code"})
152+ return
153+ }
154+ }
155+ if c, err := r.Cookie("pending"); err == nil {
156+ s.store.db.Exec(`delete from mailtokens where token = ?`, c.Value)
157+ }
158+ http.SetCookie(w, &http.Cookie{Name: "pending", Value: "", Path: "/", MaxAge: -1})
159+ s.startSession(w, u)
160+ http.Redirect(w, r, "/", http.StatusSeeOther)
161+}
162+
163+func (s *Server) setupTOTP(w http.ResponseWriter, r *http.Request) {
164+ u := s.require(w, r)
165+ if u == nil {
166+ return
167+ }
168+ if _, ok := s.store.CheckPassword(u.Name, r.FormValue("password")); !ok {
169+ http.Redirect(w, r, "/settings/security?error=Wrong+password", http.StatusSeeOther)
170+ return
171+ }
172+ secret := newSecret()
173+ token, _ := s.store.CreateMailToken(u.ID, "totpsetup", secret)
174+ uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", url.PathEscape(s.host), url.PathEscape(u.Name), secret, url.QueryEscape(s.host))
175+ png, _ := qrcode.Encode(uri, qrcode.Medium, 200)
176+ s.render(w, r, "totpsetup", page{Title: "Enable two factor", User: u, Data: map[string]any{"Settings": "security", "Secret": secret, "Token": token, "QR": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)), "URI": uri}})
177+}
178+
179+func (s *Server) enableTOTP(w http.ResponseWriter, r *http.Request) {
180+ u := s.require(w, r)
181+ if u == nil {
182+ return
183+ }
184+ var secret string
185+ var created int64
186+ err := s.store.db.QueryRow(`select email, created from mailtokens where token = ? and kind = 'totpsetup' and user = ?`, r.FormValue("token"), u.ID).Scan(&secret, &created)
187+ if err != nil || !totpValid(secret, r.FormValue("code")) {
188+ http.Redirect(w, r, "/settings/security?error=That+code+did+not+match%2C+start+again", http.StatusSeeOther)
189+ return
190+ }
191+ s.store.db.Exec(`delete from mailtokens where token = ?`, r.FormValue("token"))
192+ codes, hashes := newRecoveryCodes()
193+ s.store.SetTOTP(u.ID, secret, hashes)
194+ s.render(w, r, "recovery", page{Title: "Recovery codes", User: u, Data: map[string]any{"Settings": "security", "Codes": codes}})
195+}
196+
197+func (s *Server) disableTOTP(w http.ResponseWriter, r *http.Request) {
198+ u := s.require(w, r)
199+ if u == nil {
200+ return
201+ }
202+ if _, ok := s.store.CheckPassword(u.Name, r.FormValue("password")); !ok {
203+ http.Redirect(w, r, "/settings/security?error=Wrong+password", http.StatusSeeOther)
204+ return
205+ }
206+ s.store.SetTOTP(u.ID, "", "")
207+ http.Redirect(w, r, "/settings/security", http.StatusSeeOther)
208+}

extra.go added

@@ -0,0 +1,304 @@
1+package main
2+
3+import (
4+ "context"
5+ "encoding/xml"
6+ "fmt"
7+ "html/template"
8+ "net/http"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) {
18+ u := s.require(w, r)
19+ if u == nil {
20+ return
21+ }
22+ s.store.Star(u.ID, repo.ID, !s.store.Starred(u.ID, repo.ID))
23+ http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
24+}
25+
26+func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
27+ rp, _, _ := s.repoPage(r, repo, "")
28+ if !rp.IsOwner {
29+ s.fail(w, r, 403, "Not your repository")
30+ return
31+ }
32+ name := strings.TrimSpace(r.FormValue("name"))
33+ back := "/" + repo.Path() + "/settings"
34+ if name == repo.Name {
35+ http.Redirect(w, r, back, http.StatusSeeOther)
36+ return
37+ }
38+ if err := s.store.RenameRepo(repo.ID, name); err != nil {
39+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
40+ return
41+ }
42+ renamed := repo
43+ renamed.Name = name
44+ if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil {
45+ s.store.RenameRepo(repo.ID, repo.Name)
46+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
47+ return
48+ }
49+ http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther)
50+}
51+
52+func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
53+ rp, _, _ := s.repoPage(r, repo, "")
54+ if !rp.IsOwner {
55+ s.fail(w, r, 403, "Not your repository")
56+ return
57+ }
58+ back := "/" + repo.Path() + "/settings"
59+ target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(r.FormValue("owner"), "~")))
60+ if err != nil {
61+ http.Redirect(w, r, back+"?error=No+such+user", http.StatusSeeOther)
62+ return
63+ }
64+ if target.ID == repo.Owner.ID {
65+ http.Redirect(w, r, back, http.StatusSeeOther)
66+ return
67+ }
68+ if r.FormValue("confirm") != repo.Name {
69+ http.Redirect(w, r, back+"?error=Type+the+repository+name+to+confirm+the+transfer", http.StatusSeeOther)
70+ return
71+ }
72+ if err := s.store.TransferRepo(repo.ID, target.ID); err != nil {
73+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
74+ return
75+ }
76+ moved := repo
77+ moved.Owner = target
78+ os.MkdirAll(filepath.Dir(s.repoDir(moved)), 0o755)
79+ if err := os.Rename(s.repoDir(repo), s.repoDir(moved)); err != nil {
80+ s.store.TransferRepo(repo.ID, repo.Owner.ID)
81+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
82+ return
83+ }
84+ s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()),
85+ fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path()))
86+ http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther)
87+}
88+
89+func (s *Server) archiveRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
90+ rp, _, _ := s.repoPage(r, repo, "")
91+ if !rp.IsOwner {
92+ s.fail(w, r, 403, "Not your repository")
93+ return
94+ }
95+ s.store.SetArchived(repo.ID, !repo.Archived)
96+ http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther)
97+}
98+
99+type BlameLine struct {
100+ Hash string
101+ Author string
102+ Time time.Time
103+ Line int
104+ Text string
105+ First bool
106+ Subject string
107+}
108+
109+func (g Git) Blame(commit, filePath string) ([]BlameLine, error) {
110+ out, err := g.run(nil, "blame", "--porcelain", commit, "--", filePath)
111+ if err != nil {
112+ return nil, err
113+ }
114+ meta := map[string]*BlameLine{}
115+ var lines []BlameLine
116+ var cur *BlameLine
117+ for _, line := range strings.Split(string(out), "\n") {
118+ switch {
119+ case strings.HasPrefix(line, "\t"):
120+ if cur != nil {
121+ cur.Text = line[1:]
122+ lines = append(lines, *cur)
123+ cur = nil
124+ }
125+ case cur == nil:
126+ f := strings.Fields(line)
127+ if len(f) < 3 || len(f[0]) != 40 {
128+ continue
129+ }
130+ n, _ := strconv.Atoi(f[2])
131+ base := meta[f[0]]
132+ if base == nil {
133+ base = &BlameLine{Hash: f[0]}
134+ meta[f[0]] = base
135+ }
136+ cur = &BlameLine{Hash: f[0], Author: base.Author, Time: base.Time, Subject: base.Subject, Line: n}
137+ case strings.HasPrefix(line, "author "):
138+ meta[cur.Hash].Author = strings.TrimPrefix(line, "author ")
139+ cur.Author = meta[cur.Hash].Author
140+ case strings.HasPrefix(line, "author-time "):
141+ t, _ := strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64)
142+ meta[cur.Hash].Time = time.Unix(t, 0)
143+ cur.Time = meta[cur.Hash].Time
144+ case strings.HasPrefix(line, "summary "):
145+ meta[cur.Hash].Subject = strings.TrimPrefix(line, "summary ")
146+ cur.Subject = meta[cur.Hash].Subject
147+ }
148+ }
149+ prev := ""
150+ for i := range lines {
151+ lines[i].First = lines[i].Hash != prev
152+ prev = lines[i].Hash
153+ }
154+ return lines, nil
155+}
156+
157+func (s *Server) blame(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
158+ g := Git{Dir: s.repoDir(repo)}
159+ ref, filePath, ok := s.splitRef(g, spec)
160+ if !ok {
161+ s.fail(w, r, 404, "No such ref")
162+ return
163+ }
164+ filePath, ok = cleanPath(filePath)
165+ if !ok || filePath == "" {
166+ s.fail(w, r, 404, "No such path")
167+ return
168+ }
169+ rp, g, err := s.repoPage(r, repo, ref)
170+ if err != nil {
171+ s.fail(w, r, 404, "No such ref")
172+ return
173+ }
174+ rp.Tab = "tree"
175+ if g.Size(rp.Commit, filePath) > maxRender {
176+ s.fail(w, r, 413, "File too large to blame")
177+ return
178+ }
179+ lines, err := g.Blame(rp.Commit, filePath)
180+ if err != nil {
181+ s.fail(w, r, 404, "No such path")
182+ return
183+ }
184+ crumbs := strings.Split(filePath, "/")
185+ data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Lines": lines, "File": true, "Blame": true}
186+ if entry := g.Entry(rp.Commit, filePath); entry != nil {
187+ data["Entry"] = entry
188+ }
189+ if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 {
190+ data["LastCommit"] = last[0]
191+ }
192+ s.render(w, r, "blame", page{Title: repo.Path() + "/" + filePath + " blame", Data: data})
193+}
194+
195+func (s *Server) compare(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
196+ rp, g, err := s.repoPage(r, repo, "")
197+ if err != nil {
198+ s.fail(w, r, 404, "No such ref")
199+ return
200+ }
201+ rp.Tab = "log"
202+ branches, _ := g.Refs("refs/heads")
203+ data := map[string]any{"Page": rp, "Branches": branches}
204+ base, head, ok := strings.Cut(spec, "...")
205+ if !ok {
206+ base, head = r.URL.Query().Get("base"), r.URL.Query().Get("head")
207+ }
208+ data["Base"], data["Head"] = base, head
209+ if base != "" && head != "" {
210+ bh, err1 := g.Resolve(base)
211+ hh, err2 := g.Resolve(head)
212+ if err1 != nil || err2 != nil {
213+ s.fail(w, r, 404, "No such ref")
214+ return
215+ }
216+ if mb, err := g.MergeBase(bh, hh); err == nil {
217+ commits, _ := g.LogRange(mb, hh, 200)
218+ files, _ := g.DiffRange(mb, hh)
219+ added, removed := 0, 0
220+ for _, f := range files {
221+ added += f.Added
222+ removed += f.Removed
223+ }
224+ data["Commits"], data["Files"], data["Added"], data["Removed"], data["HeadHash"] = commits, files, added, removed, hh
225+ } else {
226+ data["Unrelated"] = true
227+ }
228+ }
229+ s.render(w, r, "compare", page{Title: repo.Path() + " compare", Data: data})
230+}
231+
232+func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spec string) {
233+ g := Git{Dir: s.repoDir(repo)}
234+ format := ""
235+ switch {
236+ case strings.HasSuffix(spec, ".tar.gz"):
237+ format, spec = "tar.gz", strings.TrimSuffix(spec, ".tar.gz")
238+ case strings.HasSuffix(spec, ".zip"):
239+ format, spec = "zip", strings.TrimSuffix(spec, ".zip")
240+ default:
241+ s.fail(w, r, 404, "Use .tar.gz or .zip")
242+ return
243+ }
244+ commit, err := g.Resolve(spec)
245+ if err != nil {
246+ s.fail(w, r, 404, "No such ref")
247+ return
248+ }
249+ safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec)
250+ name := repo.Name + "-" + safe
251+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
252+ defer cancel()
253+ cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "archive", "--format="+format, "--prefix="+name+"/", commit)
254+ cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
255+ w.Header().Set("Content-Type", "application/octet-stream")
256+ w.Header().Set("Content-Disposition", `attachment; filename="`+name+"."+format+`"`)
257+ cmd.Stdout = w
258+ cmd.Run()
259+}
260+
261+type atomFeed struct {
262+ XMLName xml.Name `xml:"feed"`
263+ NS string `xml:"xmlns,attr"`
264+ Title string `xml:"title"`
265+ ID string `xml:"id"`
266+ Updated string `xml:"updated"`
267+ Link atomLink `xml:"link"`
268+ Entries []atomEntry `xml:"entry"`
269+}
270+
271+type atomLink struct {
272+ Href string `xml:"href,attr"`
273+}
274+
275+type atomEntry struct {
276+ Title string `xml:"title"`
277+ ID string `xml:"id"`
278+ Updated string `xml:"updated"`
279+ Author string `xml:"author>name"`
280+ Link atomLink `xml:"link"`
281+ Content string `xml:"content"`
282+}
283+
284+func (s *Server) feed(w http.ResponseWriter, r *http.Request, repo Repo) {
285+ rp, g, err := s.repoPage(r, repo, r.URL.Query().Get("ref"))
286+ if err != nil {
287+ s.fail(w, r, 404, "No such ref")
288+ return
289+ }
290+ base := "https://" + s.host + "/" + repo.Path()
291+ feed := atomFeed{NS: "http://www.w3.org/2005/Atom", Title: repo.Path() + " commits on " + rp.Ref, ID: base + "/feed", Link: atomLink{Href: base}, Updated: time.Now().UTC().Format(time.RFC3339)}
292+ if !rp.Empty {
293+ commits, _ := g.Log(rp.Commit, 30, "")
294+ for _, c := range commits {
295+ feed.Entries = append(feed.Entries, atomEntry{Title: c.Subject, ID: base + "/commit/" + c.Hash, Updated: c.Time.UTC().Format(time.RFC3339), Author: c.Author, Link: atomLink{Href: base + "/commit/" + c.Hash}, Content: c.Body})
296+ }
297+ if len(commits) > 0 {
298+ feed.Updated = commits[0].Time.UTC().Format(time.RFC3339)
299+ }
300+ }
301+ w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
302+ w.Write([]byte(xml.Header))
303+ xml.NewEncoder(w).Encode(feed)
304+}

go.mod modified

@@ -20,6 +20,7 @@ require (
2020 github.com/mattn/go-isatty v0.0.24 // indirect
2121 github.com/ncruces/go-strftime v1.1.0 // indirect
2222 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
23+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
2324 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect
2425 golang.org/x/image v0.48.0 // indirect
2526 golang.org/x/net v0.62.0 // indirect

go.sum modified

@@ -31,6 +31,8 @@ github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2K
3131 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3232 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
3333 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
34+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
35+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
3436 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
3537 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
3638 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

issues.go added

@@ -0,0 +1,248 @@
1+package main
2+
3+import (
4+ "database/sql"
5+ "errors"
6+ "fmt"
7+ "html/template"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+)
13+
14+type Issue struct {
15+ ID int64
16+ Number int64
17+ Author User
18+ Title string
19+ Body string
20+ State string
21+ Created time.Time
22+ Updated time.Time
23+ Comments int
24+}
25+
26+func (s *Store) CreateIssue(repo Repo, author User, title, body string) (Issue, error) {
27+ title = strings.TrimSpace(title)
28+ if title == "" || len(title) > 200 {
29+ return Issue{}, errors.New("Title is required and at most 200 characters")
30+ }
31+ if len(body) > 20000 {
32+ return Issue{}, errors.New("Body is too long")
33+ }
34+ now := time.Now().Unix()
35+ tx, err := s.db.Begin()
36+ if err != nil {
37+ return Issue{}, err
38+ }
39+ defer tx.Rollback()
40+ var number int64
41+ tx.QueryRow(`select coalesce(max(number), 0) + 1 from issues where repo = ?`, repo.ID).Scan(&number)
42+ res, err := tx.Exec(`insert into issues(repo, number, author, title, body, created, updated) values (?, ?, ?, ?, ?, ?, ?)`, repo.ID, number, author.ID, title, body, now, now)
43+ if err != nil {
44+ return Issue{}, err
45+ }
46+ if err := tx.Commit(); err != nil {
47+ return Issue{}, err
48+ }
49+ id, _ := res.LastInsertId()
50+ return Issue{ID: id, Number: number, Author: author, Title: title, Body: body, State: "open"}, nil
51+}
52+
53+const issueColumns = `i.id, i.number, i.title, i.body, i.state, i.created, i.updated, (select count(*) from issue_comments c where c.issue = i.id), u.id, u.name, u.created from issues i join users u on u.id = i.author`
54+
55+func scanIssues(rows *sql.Rows) ([]Issue, error) {
56+ defer rows.Close()
57+ var out []Issue
58+ for rows.Next() {
59+ var i Issue
60+ var created, updated, uCreated int64
61+ if err := rows.Scan(&i.ID, &i.Number, &i.Title, &i.Body, &i.State, &created, &updated, &i.Comments, &i.Author.ID, &i.Author.Name, &uCreated); err != nil {
62+ return nil, err
63+ }
64+ i.Created, i.Updated, i.Author.Created = time.Unix(created, 0), time.Unix(updated, 0), time.Unix(uCreated, 0)
65+ out = append(out, i)
66+ }
67+ return out, rows.Err()
68+}
69+
70+func (s *Store) Issues(repo int64, state string) ([]Issue, error) {
71+ rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and (? = '' or i.state = ?) order by i.updated desc`, repo, state, state)
72+ if err != nil {
73+ return nil, err
74+ }
75+ return scanIssues(rows)
76+}
77+
78+func (s *Store) Issue(repo, number int64) (Issue, error) {
79+ rows, err := s.db.Query(`select `+issueColumns+` where i.repo = ? and i.number = ?`, repo, number)
80+ if err != nil {
81+ return Issue{}, err
82+ }
83+ issues, err := scanIssues(rows)
84+ if err != nil || len(issues) == 0 {
85+ return Issue{}, ErrNotFound
86+ }
87+ return issues[0], nil
88+}
89+
90+func (s *Store) OpenIssueCount(repo int64) int {
91+ var n int
92+ s.db.QueryRow(`select count(*) from issues where repo = ? and state = 'open'`, repo).Scan(&n)
93+ return n
94+}
95+
96+func (s *Store) SetIssueState(id int64, state string) {
97+ s.db.Exec(`update issues set state = ?, updated = ? where id = ?`, state, time.Now().Unix(), id)
98+}
99+
100+func (s *Store) AddIssueComment(issue int64, author User, body string) error {
101+ body = strings.TrimSpace(body)
102+ if body == "" || len(body) > 20000 {
103+ return errors.New("Comment is empty or too long")
104+ }
105+ _, err := s.db.Exec(`insert into issue_comments(issue, author, body, created) values (?, ?, ?, ?)`, issue, author.ID, body, time.Now().Unix())
106+ s.db.Exec(`update issues set updated = ? where id = ?`, time.Now().Unix(), issue)
107+ return err
108+}
109+
110+func (s *Store) IssueComments(issue int64) ([]Comment, error) {
111+ rows, err := s.db.Query(`select c.id, c.body, c.created, u.id, u.name, u.created from issue_comments c join users u on u.id = c.author where c.issue = ? order by c.id`, issue)
112+ if err != nil {
113+ return nil, err
114+ }
115+ defer rows.Close()
116+ var out []Comment
117+ for rows.Next() {
118+ var c Comment
119+ var created, uCreated int64
120+ if err := rows.Scan(&c.ID, &c.Body, &created, &c.Author.ID, &c.Author.Name, &uCreated); err != nil {
121+ return nil, err
122+ }
123+ c.Created, c.Author.Created = time.Unix(created, 0), time.Unix(uCreated, 0)
124+ out = append(out, c)
125+ }
126+ return out, rows.Err()
127+}
128+
129+func (s *Server) issues(w http.ResponseWriter, r *http.Request, repo Repo) {
130+ rp, _, err := s.repoPage(r, repo, "")
131+ if err != nil {
132+ s.fail(w, r, 404, "No such ref")
133+ return
134+ }
135+ rp.Tab = "issues"
136+ state := r.URL.Query().Get("state")
137+ if state == "" {
138+ state = "open"
139+ }
140+ if state == "all" {
141+ state = ""
142+ }
143+ issues, err := s.store.Issues(repo.ID, state)
144+ if err != nil {
145+ s.fail(w, r, 500, err.Error())
146+ return
147+ }
148+ s.render(w, r, "issues", page{Title: repo.Path() + " issues", Data: map[string]any{"Page": rp, "Issues": issues, "State": r.URL.Query().Get("state")}})
149+}
150+
151+func (s *Server) newIssue(w http.ResponseWriter, r *http.Request, repo Repo) {
152+ rp, _, _ := s.repoPage(r, repo, "")
153+ rp.Tab = "issues"
154+ if rp.User == nil {
155+ http.Redirect(w, r, "/login", http.StatusSeeOther)
156+ return
157+ }
158+ s.render(w, r, "newissue", page{Title: "New issue", Data: map[string]any{"Page": rp}})
159+}
160+
161+func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo) {
162+ rp, _, _ := s.repoPage(r, repo, "")
163+ rp.Tab = "issues"
164+ user := rp.User
165+ if user == nil {
166+ http.Redirect(w, r, "/login", http.StatusSeeOther)
167+ return
168+ }
169+ if repo.Archived {
170+ s.fail(w, r, 403, "This repository is archived")
171+ return
172+ }
173+ issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")))
174+ if err != nil {
175+ w.WriteHeader(400)
176+ s.render(w, r, "newissue", page{Title: "New issue", Error: err.Error(), Data: map[string]any{"Page": rp, "IssueTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
177+ return
178+ }
179+ if repo.Owner.ID != user.ID {
180+ s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title),
181+ fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", user.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number))
182+ }
183+ http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), http.StatusSeeOther)
184+}
185+
186+func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest string) {
187+ rp, _, _ := s.repoPage(r, repo, "")
188+ rp.Tab = "issues"
189+ numStr, action, _ := strings.Cut(rest, "/")
190+ number, err := strconv.ParseInt(numStr, 10, 64)
191+ if err != nil {
192+ s.fail(w, r, 404, "No such issue")
193+ return
194+ }
195+ issue, err := s.store.Issue(repo.ID, number)
196+ if err != nil {
197+ s.fail(w, r, 404, "No such issue")
198+ return
199+ }
200+ user := rp.User
201+ canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID)
202+ back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number)
203+ if r.Method == "POST" {
204+ if user == nil {
205+ http.Redirect(w, r, "/login", http.StatusSeeOther)
206+ return
207+ }
208+ subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title)
209+ link := fmt.Sprintf("https://%s%s", s.host, back)
210+ switch action {
211+ case "comment":
212+ if repo.Archived {
213+ s.fail(w, r, 403, "This repository is archived")
214+ return
215+ }
216+ if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil {
217+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
218+ return
219+ }
220+ for _, id := range []int64{issue.Author.ID, repo.Owner.ID} {
221+ if id != user.ID {
222+ s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link))
223+ }
224+ }
225+ case "close":
226+ if canManage {
227+ s.store.SetIssueState(issue.ID, "closed")
228+ }
229+ case "reopen":
230+ if canManage {
231+ s.store.SetIssueState(issue.ID, "open")
232+ }
233+ default:
234+ s.fail(w, r, 404, "Page not found")
235+ return
236+ }
237+ http.Redirect(w, r, back, http.StatusSeeOther)
238+ return
239+ }
240+ comments, _ := s.store.IssueComments(issue.ID)
241+ rendered := make([]map[string]any, len(comments))
242+ for i, c := range comments {
243+ rendered[i] = map[string]any{"Comment": c, "Body": s.renderMarkdown([]byte(c.Body))}
244+ }
245+ body := s.renderMarkdown([]byte(issue.Body))
246+ s.render(w, r, "issue", page{Title: fmt.Sprintf("#%d %s", issue.Number, issue.Title), Error: r.URL.Query().Get("error"), Math: hasMath(body),
247+ Data: map[string]any{"Page": rp, "Issue": issue, "Body": body, "Comments": rendered, "CanManage": canManage}})
248+}

mail.go added

@@ -0,0 +1,103 @@
1+package main
2+
3+import (
4+ "crypto/tls"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "log"
9+ "net"
10+ "net/smtp"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+)
16+
17+type Mailer struct {
18+ Host string `json:"host"`
19+ Port int `json:"port"`
20+ User string `json:"user"`
21+ Password string `json:"password"`
22+ From string `json:"from"`
23+ StartTLS bool `json:"starttls"`
24+ Insecure bool `json:"insecure"`
25+}
26+
27+func loadMailer(root string) *Mailer {
28+ b, err := os.ReadFile(filepath.Join(root, "smtp.json"))
29+ if err != nil {
30+ return nil
31+ }
32+ var m Mailer
33+ if json.Unmarshal(b, &m) != nil || m.Host == "" || m.From == "" {
34+ return nil
35+ }
36+ if m.Port == 0 {
37+ m.Port = 465
38+ }
39+ return &m
40+}
41+
42+func (m *Mailer) Send(to, subject, body string) error {
43+ if m == nil {
44+ return errors.New("mail is not configured")
45+ }
46+ addr := net.JoinHostPort(m.Host, fmt.Sprint(m.Port))
47+ tlsConfig := &tls.Config{ServerName: m.Host, InsecureSkipVerify: m.Insecure}
48+ var client *smtp.Client
49+ var err error
50+ if m.StartTLS {
51+ client, err = smtp.Dial(addr)
52+ if err == nil {
53+ err = client.StartTLS(tlsConfig)
54+ }
55+ } else if m.Insecure && m.Port != 465 {
56+ client, err = smtp.Dial(addr)
57+ } else {
58+ var conn net.Conn
59+ conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsConfig)
60+ if err == nil {
61+ client, err = smtp.NewClient(conn, m.Host)
62+ }
63+ }
64+ if err != nil {
65+ return err
66+ }
67+ defer client.Close()
68+ if m.User != "" {
69+ if err := client.Auth(smtp.PlainAuth("", m.User, m.Password, m.Host)); err != nil {
70+ return err
71+ }
72+ }
73+ if err := client.Mail(m.From); err != nil {
74+ return err
75+ }
76+ if err := client.Rcpt(to); err != nil {
77+ return err
78+ }
79+ w, err := client.Data()
80+ if err != nil {
81+ return err
82+ }
83+ msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
84+ m.From, to, strings.ReplaceAll(subject, "\n", " "), time.Now().Format(time.RFC1123Z), body)
85+ if _, err := w.Write([]byte(msg)); err != nil {
86+ return err
87+ }
88+ if err := w.Close(); err != nil {
89+ return err
90+ }
91+ return client.Quit()
92+}
93+
94+func (s *Server) notify(to, subject, body string) {
95+ if to == "" || s.mailer == nil {
96+ return
97+ }
98+ go func() {
99+ if err := s.mailer.Send(to, subject, body); err != nil {
100+ log.Printf("mail to %s: %v", to, err)
101+ }
102+ }()
103+}

main.go modified

@@ -21,7 +21,10 @@ func main() {
2121 shell user forced command for ssh git access
2222 invite print a single use registration code
2323 quota [size] show or set the per user disk quota, 0 disables
24- user name create a user, password read from stdin`)
24+ user name create a user, password read from stdin
25+ passwd name set a user's password, read from stdin
26+ ban name | unban name block or restore an account
27+ delete name remove an account and its repositories`)
2528 }
2629 flag.Parse()
2730 args := flag.Args()
@@ -72,6 +75,40 @@ func main() {
7275 if _, err := store.CreateUser(args[1], password); err != nil {
7376 log.Fatal(err)
7477 }
78+ case "passwd":
79+ if len(args) != 2 {
80+ os.Exit(2)
81+ }
82+ store := mustStore(*root)
83+ u, err := store.UserByName(args[1])
84+ if err != nil {
85+ log.Fatal("no such user")
86+ }
87+ var password string
88+ fmt.Fscanln(os.Stdin, &password)
89+ if err := store.SetPassword(u.ID, password); err != nil {
90+ log.Fatal(err)
91+ }
92+ store.DeleteSessions(u.ID)
93+ case "ban", "unban":
94+ if len(args) != 2 {
95+ os.Exit(2)
96+ }
97+ if err := mustStore(*root).SetBanned(args[1], args[0] == "ban"); err != nil {
98+ log.Fatal(err)
99+ }
100+ case "delete":
101+ if len(args) != 2 {
102+ os.Exit(2)
103+ }
104+ store := mustStore(*root)
105+ if _, err := store.UserByName(args[1]); err != nil {
106+ log.Fatal("no such user")
107+ }
108+ if err := store.DeleteUser(args[1]); err != nil {
109+ log.Fatal(err)
110+ }
111+ os.RemoveAll(filepath.Join(*root, "repos", args[1]))
75112 default:
76113 flag.Usage()
77114 os.Exit(2)

pull.go modified

@@ -409,6 +409,10 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
409409 s.render(w, r, "newpull", page{Title: "New pull request", User: user, Error: msg,
410410 Data: map[string]any{"Page": rp, "Sources": s.pullSources(repo, user), "Branches": branches, "Source": r.FormValue("source"), "Target": r.FormValue("target"), "PullTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
411411 }
412+ if repo.Archived {
413+ retry("This repository is archived")
414+ return
415+ }
412416 src, branch, err := s.resolveSource(repo, user, r.FormValue("source"))
413417 if err != nil {
414418 retry(err.Error())
@@ -432,6 +436,11 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
432436 retry("Could not fetch the source branch: " + err.Error())
433437 return
434438 }
439+ url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number)
440+ if repo.Owner.ID != user.ID {
441+ s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title),
442+ fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\n%s\n", user.Name, repo.Path(), pull.Body, url))
443+ }
435444 http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number), http.StatusSeeOther)
436445 }
437446
@@ -473,6 +482,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
473482 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
474483 return
475484 }
485+ s.notifyPull(pull, *user, "commented on", r.FormValue("body"))
476486 case "close":
477487 if (isOwner || isAuthor) && pull.State == "open" {
478488 s.store.SetPullState(pull.ID, "closed", "")
@@ -488,6 +498,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
488498 return
489499 }
490500 back += "#r-" + r.FormValue("path") + ":" + r.FormValue("side") + ":" + r.FormValue("line")
501+ s.notifyPull(pull, *user, "reviewed "+r.FormValue("path")+" line "+r.FormValue("line")+" on", r.FormValue("body"))
491502 case "resolve", "unresolve", "delete":
492503 id, _ := strconv.ParseInt(r.FormValue("review"), 10, 64)
493504 owner := s.store.ReviewAuthor(id)
@@ -540,6 +551,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
540551 return
541552 }
542553 s.store.SetPullState(pull.ID, "merged", hash)
554+ s.notifyPull(pull, *user, "merged", "")
543555 default:
544556 s.fail(w, r, 404, "Page not found")
545557 return
@@ -628,6 +640,19 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
628640 s.render(w, r, "pull", page{Title: fmt.Sprintf("#%d %s", pull.Number, pull.Title), Error: r.URL.Query().Get("error"), Math: hasMath(data["Body"]), Data: data})
629641 }
630642
643+func (s *Server) notifyPull(pull Pull, actor User, verb, body string) {
644+ url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, pull.Repo.Path(), pull.Number)
645+ subject := fmt.Sprintf("[%s] pull request #%d: %s", pull.Repo.Path(), pull.Number, pull.Title)
646+ text := fmt.Sprintf("~%s %s pull request #%d.\n\n%s\n\n%s\n", actor.Name, verb, pull.Number, body, url)
647+ seen := map[int64]bool{actor.ID: true}
648+ for _, id := range []int64{pull.Author.ID, pull.Repo.Owner.ID} {
649+ if !seen[id] {
650+ seen[id] = true
651+ s.notify(s.store.VerifiedEmail(id), subject, text)
652+ }
653+ }
654+}
655+
631656 func mergeTitle(pull Pull) string {
632657 return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch)
633658 }

shell.go modified

@@ -69,6 +69,9 @@ func shell(root, name string) int {
6969 if verb == "git-receive-pack" && !canWrite {
7070 return fail("you do not have write access to " + repo.Path())
7171 }
72+ if verb == "git-receive-pack" && repo.Archived {
73+ return fail("repository is archived and read only")
74+ }
7275 if verb == "git-receive-pack" {
7376 if over, used, quota := overQuota(root, repo.Owner.Name); over {
7477 return fail(quotaMessage(used, quota))

smart.go modified

@@ -63,6 +63,10 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
6363 return
6464 }
6565 }
66+ if service == "git-receive-pack" && repo.Archived {
67+ http.Error(w, "repository is archived and read only", 403)
68+ return
69+ }
6670 if service == "git-receive-pack" {
6771 if over, used, quota := overQuota(s.root, repo.Owner.Name); over {
6872 http.Error(w, quotaMessage(used, quota), 403)

static/style.css modified

@@ -253,3 +253,15 @@ mark{background:#fff3b0;padding:0}
253253 .comment.outdated{opacity:.8}
254254 .mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem}
255255 .mergeform label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
256+.btn.starred{background:#fff3cd;border-color:#ffe08a}
257+.btn.static{cursor:default}
258+.archived{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.3rem .6rem;margin:.4rem 0 0}
259+.compareform{display:flex;gap:.6rem;align-items:flex-end;margin-bottom:1rem;flex-wrap:wrap}
260+.compareform label{display:flex;flex-direction:column;font-size:.85rem;color:var(--muted)}
261+table.blame{width:100%}
262+table.blame td{padding:0 .5rem;white-space:pre;vertical-align:top}
263+table.blame td.who{width:1%;white-space:nowrap;color:var(--muted);font-family:system-ui,sans-serif;font-size:.8rem;border-right:1px solid var(--bar);padding-right:.8rem}
264+table.blame tr.first td{border-top:1px solid var(--line)}
265+table.blame td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
266+table.blame td.n{border-right:1px solid var(--bar)}
267+.actions a.muted{margin-right:auto;align-self:center}

store.go modified

@@ -67,7 +67,49 @@ var migrations = []string{
6767 `alter table users add column display text not null default ''`,
6868 `alter table repos add column tags text not null default ''`,
6969 `alter table repos add column parent integer not null default 0`,
70-}
70+ `alter table repos add column archived integer not null default 0`,
71+ `alter table users add column email text not null default ''`,
72+ `alter table users add column verified integer not null default 0`,
73+ `alter table users add column notify integer not null default 1`,
74+ `alter table users add column banned integer not null default 0`,
75+ `alter table users add column totp text not null default ''`,
76+ `alter table users add column recovery text not null default ''`,
77+}
78+
79+const extraSchema = `
80+create table if not exists mailtokens (
81+ token text primary key,
82+ user integer not null references users(id) on delete cascade,
83+ kind text not null,
84+ email text not null,
85+ created integer not null
86+);
87+create table if not exists stars (
88+ user integer not null references users(id) on delete cascade,
89+ repo integer not null references repos(id) on delete cascade,
90+ created integer not null,
91+ primary key(user, repo)
92+);
93+create table if not exists issues (
94+ id integer primary key,
95+ repo integer not null references repos(id) on delete cascade,
96+ number integer not null,
97+ author integer not null references users(id) on delete cascade,
98+ title text not null,
99+ body text not null default '',
100+ state text not null default 'open',
101+ created integer not null,
102+ updated integer not null,
103+ unique(repo, number)
104+);
105+create table if not exists issue_comments (
106+ id integer primary key,
107+ issue integer not null references issues(id) on delete cascade,
108+ author integer not null references users(id) on delete cascade,
109+ body text not null,
110+ created integer not null
111+);
112+`
71113
72114 const collabSchema = `
73115 create table if not exists collaborators (
@@ -119,6 +161,7 @@ type Repo struct {
119161 Unlisted bool
120162 Tags []string
121163 Parent int64
164+ Archived bool
122165 Created time.Time
123166 Updated time.Time
124167 }
@@ -192,6 +235,9 @@ func openStore(path string, readonly bool) (*Store, error) {
192235 if _, err := db.Exec(collabSchema); err != nil {
193236 return nil, err
194237 }
238+ if _, err := db.Exec(extraSchema); err != nil {
239+ return nil, err
240+ }
195241 }
196242 return &Store{db}, nil
197243 }
@@ -267,7 +313,7 @@ func (s *Store) CheckPassword(name, password string) (User, bool) {
267313 var u User
268314 var hash string
269315 var created int64
270- err := s.db.QueryRow(`select id, name, hash, created from users where name = ?`, name).Scan(&u.ID, &u.Name, &hash, &created)
316+ err := s.db.QueryRow(`select id, name, hash, created from users where name = ? and banned = 0`, name).Scan(&u.ID, &u.Name, &hash, &created)
271317 if err != nil {
272318 bcrypt.CompareHashAndPassword([]byte("$2a$10$000000000000000000000uGJ8z1gQ7yP1cqeI4qzj6WmLIl3JcP6W"), []byte(password))
273319 return u, false
@@ -296,7 +342,7 @@ func (s *Store) CreateSession(user int64) (string, error) {
296342
297343 func (s *Store) SessionUser(id string) (User, error) {
298344 var user int64
299- err := s.db.QueryRow(`select user from sessions where id = ? and created > ?`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user)
345+ err := s.db.QueryRow(`select s.user from sessions s join users u on u.id = s.user where s.id = ? and s.created > ? and u.banned = 0`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user)
300346 if err != nil {
301347 return User{}, ErrNotFound
302348 }
@@ -387,7 +433,7 @@ func (s *Store) UserByKey(public string) (User, error) {
387433 return User{}, err
388434 }
389435 var user int64
390- err = s.db.QueryRow(`select user from keys where fingerprint = ?`, ssh.FingerprintSHA256(pub)).Scan(&user)
436+ err = s.db.QueryRow(`select k.user from keys k join users u on u.id = k.user where k.fingerprint = ? and u.banned = 0`, ssh.FingerprintSHA256(pub)).Scan(&user)
391437 if err != nil {
392438 return User{}, ErrNotFound
393439 }
@@ -434,7 +480,7 @@ func (s *Store) DeleteToken(user, id int64) error {
434480
435481 func (s *Store) UserByToken(name, token string) (User, bool) {
436482 var user int64
437- err := s.db.QueryRow(`select t.user from tokens t join users u on u.id = t.user where u.name = ? and t.hash = ?`, name, tokenHash(token)).Scan(&user)
483+ err := s.db.QueryRow(`select t.user from tokens t join users u on u.id = t.user where u.name = ? and t.hash = ? and u.banned = 0`, name, tokenHash(token)).Scan(&user)
438484 if err != nil {
439485 return User{}, false
440486 }
@@ -468,12 +514,13 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) {
468514 var repos []Repo
469515 for rows.Next() {
470516 var r Repo
471- var private int
517+ var private, archived int
472518 var tags string
473519 var created, ucreated int64
474- if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil {
520+ if err := rows.Scan(&r.ID, &r.Name, &r.Description, &private, &tags, &r.Parent, &archived, &created, &r.Owner.ID, &r.Owner.Name, &ucreated); err != nil {
475521 return nil, err
476522 }
523+ r.Archived = archived != 0
477524 r.Private = private == Private
478525 r.Unlisted = private == Unlisted
479526 r.Tags = splitTags(tags)
@@ -484,7 +531,7 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) {
484531 return repos, rows.Err()
485532 }
486533
487-const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
534+const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.archived, r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
488535
489536 func (s *Store) RepoByID(id int64) (Repo, error) {
490537 rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id)
@@ -718,6 +765,153 @@ func (s *Store) PublicKeys(user int64) ([]string, []string) {
718765 return ssh, pgp
719766 }
720767
768+type Account struct {
769+ Email string
770+ Verified bool
771+ Notify bool
772+ TOTP string
773+ Recovery string
774+}
775+
776+func (s *Store) Account(user int64) Account {
777+ var a Account
778+ var verified, notify int
779+ s.db.QueryRow(`select email, verified, notify, totp, recovery from users where id = ?`, user).Scan(&a.Email, &verified, &notify, &a.TOTP, &a.Recovery)
780+ a.Verified, a.Notify = verified != 0, notify != 0
781+ return a
782+}
783+
784+func (s *Store) SetEmail(user int64, email string) error {
785+ email = strings.TrimSpace(strings.ToLower(email))
786+ if email != "" && (!strings.Contains(email, "@") || len(email) > 200) {
787+ return errors.New("That does not look like an email address")
788+ }
789+ _, err := s.db.Exec(`update users set email = ?, verified = 0 where id = ?`, email, user)
790+ return err
791+}
792+
793+func (s *Store) SetNotify(user int64, on bool) {
794+ s.db.Exec(`update users set notify = ? where id = ?`, on, user)
795+}
796+
797+func (s *Store) VerifiedEmail(user int64) string {
798+ a := s.Account(user)
799+ if a.Verified && a.Notify {
800+ return a.Email
801+ }
802+ return ""
803+}
804+
805+func (s *Store) CreateMailToken(user int64, kind, email string) (string, error) {
806+ token := random(24)
807+ s.db.Exec(`delete from mailtokens where user = ? and kind = ?`, user, kind)
808+ _, err := s.db.Exec(`insert into mailtokens(token, user, kind, email, created) values (?, ?, ?, ?, ?)`, token, user, kind, email, time.Now().Unix())
809+ return token, err
810+}
811+
812+func (s *Store) UseMailToken(token, kind string) (int64, string, bool) {
813+ var user int64
814+ var email string
815+ var created int64
816+ err := s.db.QueryRow(`select user, email, created from mailtokens where token = ? and kind = ?`, token, kind).Scan(&user, &email, &created)
817+ if err != nil || time.Since(time.Unix(created, 0)) > 24*time.Hour {
818+ return 0, "", false
819+ }
820+ s.db.Exec(`delete from mailtokens where token = ?`, token)
821+ return user, email, true
822+}
823+
824+func (s *Store) MarkVerified(user int64, email string) {
825+ s.db.Exec(`update users set email = ?, verified = 1 where id = ?`, email, user)
826+}
827+
828+func (s *Store) UserByEmail(email string) (User, error) {
829+ var id int64
830+ if err := s.db.QueryRow(`select id from users where email = ? and verified = 1 and banned = 0`, strings.ToLower(strings.TrimSpace(email))).Scan(&id); err != nil {
831+ return User{}, ErrNotFound
832+ }
833+ return s.userByID(id)
834+}
835+
836+func (s *Store) SetTOTP(user int64, secret, recovery string) {
837+ s.db.Exec(`update users set totp = ?, recovery = ? where id = ?`, secret, recovery, user)
838+}
839+
840+func (s *Store) SetBanned(name string, banned bool) error {
841+ res, err := s.db.Exec(`update users set banned = ? where name = ?`, banned, name)
842+ if err != nil {
843+ return err
844+ }
845+ if n, _ := res.RowsAffected(); n == 0 {
846+ return ErrNotFound
847+ }
848+ s.db.Exec(`delete from sessions where user = (select id from users where name = ?)`, name)
849+ return nil
850+}
851+
852+func (s *Store) DeleteUser(name string) error {
853+ res, err := s.db.Exec(`delete from users where name = ?`, name)
854+ if err != nil {
855+ return err
856+ }
857+ if n, _ := res.RowsAffected(); n == 0 {
858+ return ErrNotFound
859+ }
860+ return nil
861+}
862+
863+func (s *Store) RenameRepo(id int64, name string) error {
864+ if !validName(name) || name == "avatar" {
865+ return errors.New("Repository names are lowercase letters, digits, dot, dash or underscore")
866+ }
867+ _, err := s.db.Exec(`update repos set name = ? where id = ?`, name, id)
868+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
869+ return errors.New("A repository with that name already exists")
870+ }
871+ return err
872+}
873+
874+func (s *Store) TransferRepo(id int64, owner int64) error {
875+ _, err := s.db.Exec(`update repos set owner = ? where id = ?`, owner, id)
876+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
877+ return errors.New("That user already has a repository with this name")
878+ }
879+ s.db.Exec(`delete from collaborators where repo = ? and user = ?`, id, owner)
880+ return err
881+}
882+
883+func (s *Store) SetArchived(id int64, archived bool) {
884+ s.db.Exec(`update repos set archived = ? where id = ?`, archived, id)
885+}
886+
887+func (s *Store) Star(user, repo int64, on bool) {
888+ if on {
889+ s.db.Exec(`insert or ignore into stars(user, repo, created) values (?, ?, ?)`, user, repo, time.Now().Unix())
890+ } else {
891+ s.db.Exec(`delete from stars where user = ? and repo = ?`, user, repo)
892+ }
893+}
894+
895+func (s *Store) Starred(user, repo int64) bool {
896+ var n int
897+ s.db.QueryRow(`select count(*) from stars where user = ? and repo = ?`, user, repo).Scan(&n)
898+ return n > 0
899+}
900+
901+func (s *Store) StarCount(repo int64) int {
902+ var n int
903+ s.db.QueryRow(`select count(*) from stars where repo = ?`, repo).Scan(&n)
904+ return n
905+}
906+
907+func (s *Store) StarredRepos(user int64) ([]Repo, error) {
908+ rows, err := s.db.Query(`select `+repoColumns+` join stars st on st.repo = r.id where st.user = ? and r.private = 0 order by st.created desc`, user)
909+ if err != nil {
910+ return nil, err
911+ }
912+ return scanRepos(rows)
913+}
914+
721915 func (s *Store) CreateInvite() (string, error) {
722916 code := random(12)
723917 _, err := s.db.Exec(`insert into invites(code) values (?)`, code)

templates/blame.html added

@@ -0,0 +1,11 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<div class="filebar">
4+<span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Data.Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Data.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Data.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
5+{{with .Data.Entry}}<span class="mode">{{perm .Mode}}</span><span class="muted">{{size .Size}}</span>{{end}}
6+<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Data.Path}}">Log</a><a class="active" href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Data.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Commit}}/{{.Data.Path}}">Permalink</a>
7+<span class="spacer"></span>
8+{{with .Data.LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
9+</div>
10+<div class="code"><table class="blame">{{range .Data.Lines}}<tr{{if .First}} class="first"{{end}}><td class="who">{{if .First}}<a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{.Subject}}">{{short .Hash}}</a> {{.Author}} <span class="muted">{{ago .Time}}</span>{{end}}</td><td class="n" id="L{{.Line}}"><a href="#L{{.Line}}">{{.Line}}</a></td><td class="l">{{.Text}}</td></tr>{{end}}</table></div>
11+{{end}}

templates/blob.html modified

@@ -14,7 +14,7 @@
1414 <div class="filebar">
1515 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
1616 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
17-<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>
17+<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>
1818 <span class="spacer"></span>
1919 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
2020 </div>

templates/compare.html added

@@ -0,0 +1,26 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h3 class="plain">Compare</h3>
4+<form method="get" action="/{{$p.Repo.Path}}/compare" class="compareform">
5+<label>base <input name="base" list="refs" value="{{.Data.Base}}" placeholder="main"></label>
6+<span>…</span>
7+<label>head <input name="head" list="refs" value="{{.Data.Head}}" placeholder="feature"></label>
8+<datalist id="refs">{{range .Data.Branches}}<option value="{{.Name}}">{{end}}</datalist>
9+<button class="btn">Compare ▸</button>
10+</form>
11+{{if .Data.Unrelated}}<p class="info">These refs have no common history.</p>{{end}}
12+{{if .Data.Commits}}
13+<h3>Commits <span class="muted">{{len .Data.Commits}}</span></h3>
14+<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div>
15+{{else if and .Data.Base .Data.Head (not .Data.Unrelated)}}<p class="info">{{.Data.Head}} has nothing that {{.Data.Base}} does not.</p>{{end}}
16+{{if .Data.Files}}
17+<h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3>
18+{{range .Data.Files}}
19+<section class="diff" id="{{.New}}">
20+<h4><a href="/{{$p.Repo.Path}}/tree/{{$.Data.HeadHash}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h4>
21+{{if .Binary}}<p class="muted">Binary file.</p>{{end}}
22+{{range .Hunks}}<table class="hunk"><tr class="head"><td colspan="3">{{.Header}}</td></tr>{{range .Lines}}<tr class="{{.Kind}}"><td class="n">{{if .Old}}{{.Old}}{{end}}</td><td class="n">{{if .New}}{{.New}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr>{{end}}</table>{{end}}
23+</section>
24+{{end}}
25+{{end}}
26+{{end}}

templates/issue.html added

@@ -0,0 +1,21 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+{{$i := .Data.Issue}}
4+<div class="pulltitle">
5+<h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span></h3>
6+<p class="muted"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> opened this issue {{ago $i.Created}}{{if .Data.Comments}} · {{len .Data.Comments}} comments{{end}}</p>
7+</div>
8+<div class="columns pullbody">
9+<div class="main">
10+<div class="comment"><div class="who"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> <span class="muted">{{ago $i.Created}}</span></div><div class="readme">{{if $i.Body}}{{.Data.Body}}{{else}}<p class="muted">No description.</p>{{end}}</div></div>
11+{{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}}
12+{{if and .User (not $p.Repo.Archived)}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/comment" class="stack wide">
13+<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label>
14+<div class="actions"><button class="btn">Comment ▸</button></div>
15+</form>{{else if not .User}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
16+</div>
17+<aside><div class="panel">
18+{{if .Data.CanManage}}{{if eq $i.State "open"}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/close"><button class="btn block">Close issue ▸</button></form>{{else}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}}{{else}}<p class="muted small">Only the author and the repository owner can close this.</p>{{end}}
19+</div></aside>
20+</div>
21+{{end}}

templates/issues.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<div class="pullhead">
4+<nav class="filters"><a href="?state=open"{{if or (eq .Data.State "") (eq .Data.State "open")}} class="active"{{end}}>Open</a><a href="?state=closed"{{if eq .Data.State "closed"}} class="active"{{end}}>Closed</a><a href="?state=all"{{if eq .Data.State "all"}} class="active"{{end}}>All</a></nav>
5+{{if and $p.User (not $p.Repo.Archived)}}<a class="btn primary" href="/{{$p.Repo.Path}}/issues/new">New issue ▸</a>{{end}}
6+</div>
7+{{if .Data.Issues}}<div class="events">{{range .Data.Issues}}<div class="event pull"><h4><span class="state {{.State}}">{{.State}}</span> <a href="/{{$p.Repo.Path}}/issues/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span></h4><p class="muted small">opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}} · updated {{ago .Updated}}</p></div>{{end}}</div>{{else}}<p class="info">No issues here.</p>{{end}}
8+{{end}}

templates/layout.html modified

@@ -30,10 +30,10 @@
3030 </header>
3131 {{with .Data}}{{with index . "Page"}}
3232 <div class="repohead">
33-<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
33+<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}">{{if .Starred}}★ Starred{{else}}☆ Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static">☆ {{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn">Fork ▸</button></form>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
3434 <div class="tabbar"><nav class="tabs inner">
3535 <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
36-{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}
36+{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a>
3737 {{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}}
3838 </nav></div>
3939 </div>

templates/login.html modified

@@ -4,7 +4,7 @@
44 <form method="post" action="/login" class="stack centred">
55 <label>Username <input name="name" required autofocus autocomplete="username"></label>
66 <label>Password <input name="password" type="password" required autocomplete="current-password"></label>
7-<div class="actions"><button class="btn primary">Log in ▸</button></div>
7+<div class="actions"><a class="muted small" href="/reset">Forgot your password?</a><button class="btn primary">Log in ▸</button></div>
88 </form>
99 </div>
1010 {{end}}

templates/message.html added

@@ -0,0 +1,3 @@
1+{{define "content"}}
2+<div class="panel narrow"><h3>{{.Data.Heading}}</h3><p>{{.Data.Text}}</p><p><a href="/">Back to the front page</a></p></div>
3+{{end}}

templates/newissue.html added

@@ -0,0 +1,9 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h3>New issue</h3>
4+<form method="post" action="/{{$p.Repo.Path}}/issues/new" class="stack wide">
5+<label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.IssueTitle}}"></label>
6+<label>Description <textarea name="body" rows="10" placeholder="Markdown supported">{{.Data.Body}}</textarea></label>
7+<div class="actions"><button class="btn primary">Open issue ▸</button></div>
8+</form>
9+{{end}}

templates/recovery.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+<div class="panel"><h3>Two factor enabled</h3>
3+<p>These recovery codes each work once in place of an authenticator code. Store them somewhere safe, they will not be shown again.</p>
4+<pre>{{range .Data.Codes}}{{.}}
5+{{end}}</pre>
6+<a class="btn" href="/settings/security">Done ▸</a>
7+</div>
8+{{end}}

templates/refs.html modified

@@ -3,5 +3,5 @@
33 <div class="panel"><h3>Branches</h3>
44 <table class="refs">{{range .Data.Branches}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table></div>
55 <div class="panel"><h3>Tags</h3>
6-{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div>
6+{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td><td><a href="/{{$p.Repo.Path}}/archive/{{.Name}}.tar.gz">tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{.Name}}.zip">zip</a></td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div>
77 {{end}}

templates/repo.html modified

@@ -20,6 +20,9 @@ git push -u origin {{$p.Ref}}</pre>
2020 <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div>
2121 {{if $p.Owner}}<strong>read/write</strong>
2222 <div>{{.SSHUser}}@{{.Host}}:{{$p.Repo.Path}}</div>{{end}}
23+<strong>download</strong>
24+<div><a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.tar.gz">{{$p.Ref}}.tar.gz</a> · <a href="/{{$p.Repo.Path}}/archive/{{$p.Ref}}.zip">{{$p.Ref}}.zip</a></div>
25+<div><a href="/{{$p.Repo.Path}}/feed">atom feed</a> · <a href="/{{$p.Repo.Path}}/compare">compare</a></div>
2326 </div>
2427 </div>
2528 <div class="readme">

templates/reposettings.html modified

@@ -14,6 +14,21 @@
1414 <label class="radio"><input type="radio" name="visibility" value="private"{{if $p.Repo.Private}} checked{{end}}> Private<small>Only visible to you</small></label>
1515 </div>
1616 </form>
17+<h3>Rename</h3>
18+<form method="post" action="/{{$p.Repo.Path}}/rename" class="stack">
19+<label>New name <input name="name" value="{{$p.Repo.Name}}" required pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
20+<small>Old URLs stop working, forks and pull requests follow the repository.</small>
21+<div class="actions left"><button class="btn primary">Rename ▸</button></div>
22+</form>
23+<h3>Transfer</h3>
24+<form method="post" action="/{{$p.Repo.Path}}/transfer" class="stack">
25+<label>New owner <input name="owner" placeholder="~someone" required></label>
26+<label>Type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label>
27+<div class="actions left"><button class="btn danger">Transfer repository ▸</button></div>
28+</form>
29+<h3>Archive</h3>
30+<p>{{if $p.Repo.Archived}}This repository is archived, nobody can push, open pull requests or comment.{{else}}Archiving makes the repository read only. Nobody can push, open pull requests or comment until it is unarchived.{{end}}</p>
31+<form method="post" action="/{{$p.Repo.Path}}/archive"><button class="btn">{{if $p.Repo.Archived}}Unarchive ▸{{else}}Archive ▸{{end}}</button></form>
1732 <h3>Collaborators</h3>
1833 <p>Collaborators can push, read the repository if it is private, and merge pull requests. Only you can change settings or delete it.</p>
1934 {{if .Data.Collaborators}}<table class="keys">{{range .Data.Collaborators}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/{{$p.Repo.Path}}/collaborators"><input type="hidden" name="remove" value="{{.ID}}"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}

templates/reset.html added

@@ -0,0 +1,13 @@
1+{{define "content"}}
2+<div class="auth">
3+<h3 class="rule">Reset your password <small>or <a href="/login">log in</a></small></h3>
4+{{if .Data.Sent}}<p class="notice">If that account has a verified email address, a reset link is on its way. It is valid for 24 hours.</p>{{end}}
5+{{if .Data.Mail}}
6+<p>Enter your username or verified email address and a link to choose a new password will be sent.</p>
7+<form method="post" action="/reset" class="stack centred">
8+<label>Username or email <input name="who" required autofocus></label>
9+<div class="actions"><button class="btn primary">Send reset link ▸</button></div>
10+</form>
11+{{else}}<p>This server has no outgoing mail configured, so passwords are reset by the administrator with <code>basin passwd</code>.</p>{{end}}
12+</div>
13+{{end}}

templates/resetpassword.html added

@@ -0,0 +1,9 @@
1+{{define "content"}}
2+<div class="auth">
3+<h3 class="rule">Choose a new password</h3>
4+<form method="post" action="/reset/{{.Data.Token}}" class="stack centred">
5+<label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password" autofocus></label>
6+<div class="actions"><button class="btn primary">Save password ▸</button></div>
7+</form>
8+</div>
9+{{end}}

templates/security.html modified

@@ -5,6 +5,12 @@
55 <label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
66 <div class="actions left"><button class="btn primary">Change password ▸</button></div>
77 </form></div>
8+<div class="panel"><h3>Two factor authentication</h3>
9+{{if .Data.Account.TOTP}}<p><strong>Enabled</strong> on your account. A code from your authenticator app or a recovery code is required at every login.</p>
10+<form method="post" action="/settings/totp/disable" class="stack"><label>Password to disable <input name="password" type="password" required autocomplete="current-password"></label><div class="actions left"><button class="btn danger">Disable ▸</button></div></form>
11+{{else}}<p><strong>Disabled</strong> on your account. Enable this and you will be asked for a TOTP code each time you log in. Use of two factor is strongly recommended.</p>
12+<form method="post" action="/settings/totp" class="stack"><label>Password to continue <input name="password" type="password" required autocomplete="current-password"></label><div class="actions left"><button class="btn primary">Enable TOTP ▸</button></div></form>{{end}}
13+</div>
814 <div class="panel"><h3>Sessions</h3>
915 <p>Sessions last 90 days. Logging out ends only the current one.</p>
1016 <form method="post" action="/settings/sessions"><button class="btn">Log out everywhere ▸</button></form>

templates/settings.html modified

@@ -13,6 +13,18 @@
1313 </div>
1414 </form>
1515 <script src="/static/crop.js" defer></script>
16+<h3 class="rule">Email</h3>
17+<form method="post" action="/settings/email" class="stack wide">
18+<label>Email address{{if .Data.Account.Email}}{{if .Data.Account.Verified}} <span class="muted">(verified)</span>{{else}} <span class="muted">(not verified yet)</span>{{end}}{{end}} <input name="email" type="email" value="{{.Data.Account.Email}}" placeholder="you@example.org"></label>
19+<small>Used for password resets and notifications about your repositories and pull requests. Never shown publicly.{{if not .Data.Mail}} Mail is not configured on this server yet, so verification cannot be sent.{{end}}</small>
20+{{if .Data.Sent}}<p class="notice">Verification mail sent, open the link in it to confirm.</p>{{end}}
21+<div class="actions left"><button class="btn primary">{{if .Data.Account.Verified}}Change email ▸{{else}}Save and send verification ▸{{end}}</button></div>
22+</form>
23+<form method="post" action="/settings/notify" class="stack wide">
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+<div class="actions left"><button class="btn">Save ▸</button></div>
26+</form>
27+<h3 class="rule">Profile</h3>
1628 <form method="post" action="/settings/profile" class="stack wide">
1729 <div class="pair">
1830 <label>Username <span class="muted">(you can't edit this)</span><input value="{{.User.Name}}" disabled></label>

templates/totp.html added

@@ -0,0 +1,10 @@
1+{{define "content"}}
2+<div class="auth">
3+<h3 class="rule">Two factor authentication</h3>
4+<p>Enter the six digit code from your authenticator app, or one of your recovery codes.</p>
5+<form method="post" action="/login/totp" class="stack centred">
6+<label>Code <input name="code" required autofocus autocomplete="one-time-code" inputmode="numeric"></label>
7+<div class="actions"><button class="btn primary">Continue ▸</button></div>
8+</form>
9+</div>
10+{{end}}

templates/totpsetup.html added

@@ -0,0 +1,11 @@
1+{{define "content"}}
2+<div class="panel"><h3>Enable two factor authentication</h3>
3+<div class="twocol">
4+<div><img src="{{.Data.QR}}" alt="" width="200" height="200"><p class="muted small">Scan with any TOTP app, or enter the secret by hand: <code>{{.Data.Secret}}</code></p></div>
5+<form method="post" action="/settings/totp/enable" class="stack">
6+<input type="hidden" name="token" value="{{.Data.Token}}">
7+<label>Code from the app <input name="code" required autofocus inputmode="numeric" autocomplete="one-time-code"></label>
8+<div class="actions left"><button class="btn primary">Enable ▸</button><a class="btn" href="/settings/security">Cancel</a></div>
9+</form>
10+</div></div>
11+{{end}}

templates/tree.html modified

@@ -18,7 +18,7 @@
1818 <div class="filebar">
1919 <span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
2020 {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
21-{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}}
21+{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/blame/{{$p.Ref}}/{{.Path}}">Blame</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}}
2222 <span class="spacer"></span>
2323 <form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form>
2424 {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}

templates/user.html modified

@@ -17,6 +17,7 @@
1717 </aside>
1818 <div class="main">
1919 {{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}}
20+{{with .Data.Starred}}<h3 class="plain">Starred</h3><div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a></h4>{{if .Description}}<p>{{.Description}}</p>{{end}}</div>{{end}}</div>{{end}}
2021 </div>
2122 </div>
2223 {{end}}

totp.go added

@@ -0,0 +1,77 @@
1+package main
2+
3+import (
4+ "crypto/hmac"
5+ "crypto/rand"
6+ "crypto/sha1"
7+ "crypto/sha256"
8+ "encoding/base32"
9+ "encoding/binary"
10+ "encoding/hex"
11+ "fmt"
12+ "strings"
13+ "time"
14+)
15+
16+func newSecret() string {
17+ b := make([]byte, 20)
18+ rand.Read(b)
19+ return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
20+}
21+
22+func totpCode(secret string, t time.Time) string {
23+ key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
24+ if err != nil {
25+ return ""
26+ }
27+ counter := make([]byte, 8)
28+ binary.BigEndian.PutUint64(counter, uint64(t.Unix()/30))
29+ mac := hmac.New(sha1.New, key)
30+ mac.Write(counter)
31+ sum := mac.Sum(nil)
32+ offset := sum[len(sum)-1] & 0x0f
33+ code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
34+ return fmt.Sprintf("%06d", code%1000000)
35+}
36+
37+func totpValid(secret, code string) bool {
38+ code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
39+ now := time.Now()
40+ for _, d := range []time.Duration{0, -30 * time.Second, 30 * time.Second} {
41+ if hmac.Equal([]byte(totpCode(secret, now.Add(d))), []byte(code)) {
42+ return true
43+ }
44+ }
45+ return false
46+}
47+
48+func newRecoveryCodes() ([]string, string) {
49+ var codes, hashes []string
50+ for i := 0; i < 8; i++ {
51+ b := make([]byte, 5)
52+ rand.Read(b)
53+ code := hex.EncodeToString(b)
54+ codes = append(codes, code[:5]+"-"+code[5:])
55+ sum := sha256.Sum256([]byte(code))
56+ hashes = append(hashes, hex.EncodeToString(sum[:]))
57+ }
58+ return codes, strings.Join(hashes, ",")
59+}
60+
61+func useRecovery(stored, code string) (string, bool) {
62+ code = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(code), "-", ""))
63+ sum := sha256.Sum256([]byte(code))
64+ h := hex.EncodeToString(sum[:])
65+ var kept []string
66+ found := false
67+ for _, x := range strings.Split(stored, ",") {
68+ if x == h && !found {
69+ found = true
70+ continue
71+ }
72+ if x != "" {
73+ kept = append(kept, x)
74+ }
75+ }
76+ return strings.Join(kept, ","), found
77+}

web.go modified

@@ -37,6 +37,7 @@ var templateFS embed.FS
3737 var staticFS embed.FS
3838
3939 type Server struct {
40+ mailer *Mailer
4041 version string
4142 limiter *limiter
4243 store *Store
@@ -62,7 +63,7 @@ type page struct {
6263 }
6364
6465 func newServer(store *Store, root, host string, open bool, sshUser string) *Server {
65- s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)}
66+ s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)}
6667 funcs := template.FuncMap{
6768 "short": func(h string) string {
6869 if len(h) > 10 {
@@ -163,6 +164,18 @@ func (s *Server) handler() http.Handler {
163164 mux.HandleFunc("GET /login", s.loginForm)
164165 mux.HandleFunc("POST /login", s.login)
165166 mux.HandleFunc("POST /logout", s.logout)
167+ mux.HandleFunc("GET /login/totp", s.totpForm)
168+ mux.HandleFunc("POST /login/totp", s.totpLogin)
169+ mux.HandleFunc("GET /reset", s.resetForm)
170+ mux.HandleFunc("POST /reset", s.resetRequest)
171+ mux.HandleFunc("GET /reset/{token}", s.resetTokenForm)
172+ mux.HandleFunc("POST /reset/{token}", s.resetPassword)
173+ mux.HandleFunc("GET /verify/{token}", s.verifyEmail)
174+ mux.HandleFunc("POST /settings/email", s.setEmail)
175+ mux.HandleFunc("POST /settings/notify", s.setNotify)
176+ mux.HandleFunc("POST /settings/totp", s.setupTOTP)
177+ mux.HandleFunc("POST /settings/totp/enable", s.enableTOTP)
178+ mux.HandleFunc("POST /settings/totp/disable", s.disableTOTP)
166179 mux.HandleFunc("GET /register", s.registerForm)
167180 mux.HandleFunc("POST /register", s.register)
168181 mux.HandleFunc("GET /create", s.createForm)
@@ -221,6 +234,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
221234 s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"})
222235 return
223236 }
237+ if s.store.Account(u.ID).TOTP != "" {
238+ token, _ := s.store.CreateMailToken(u.ID, "totp", "")
239+ http.SetCookie(w, &http.Cookie{Name: "pending", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600})
240+ http.Redirect(w, r, "/login/totp", http.StatusSeeOther)
241+ return
242+ }
224243 s.startSession(w, u)
225244 http.Redirect(w, r, "/", http.StatusSeeOther)
226245 }
@@ -454,7 +473,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
454473 }
455474 used := usage(filepath.Join(s.root, "repos", u.Name))
456475 s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"),
457- Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root)}})
476+ Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}})
458477 }
459478
460479 func (s *Server) security(w http.ResponseWriter, r *http.Request) {
@@ -462,7 +481,8 @@ func (s *Server) security(w http.ResponseWriter, r *http.Request) {
462481 if u == nil {
463482 return
464483 }
465- s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security"}})
484+ a := s.store.Account(u.ID)
485+ s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security", "Account": a, "Mail": s.mailer != nil}})
466486 }
467487
468488 func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) {
@@ -698,7 +718,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
698718 if profile.Bio != "" {
699719 bio = s.renderMarkdown([]byte(profile.Bio))
700720 }
701- s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio}})
721+ starred, _ := s.store.StarredRepos(owner.ID)
722+ s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio, "Starred": starred}})
702723 return
703724 }
704725 name := parts[1]
@@ -734,6 +755,30 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
734755 s.deleteRepo(w, r, repo)
735756 case rest == "collaborators" && r.Method == "POST":
736757 s.collaborators(w, r, repo)
758+ case rest == "rename" && r.Method == "POST":
759+ s.renameRepo(w, r, repo)
760+ case rest == "transfer" && r.Method == "POST":
761+ s.transferRepo(w, r, repo)
762+ case rest == "archive" && r.Method == "POST":
763+ s.archiveRepo(w, r, repo)
764+ case rest == "star" && r.Method == "POST":
765+ s.star(w, r, repo)
766+ case strings.HasPrefix(rest, "blame/") && r.Method == "GET":
767+ s.blame(w, r, repo, strings.TrimPrefix(rest, "blame/"))
768+ case strings.HasPrefix(rest, "compare") && r.Method == "GET":
769+ s.compare(w, r, repo, strings.TrimPrefix(strings.TrimPrefix(rest, "compare"), "/"))
770+ case strings.HasPrefix(rest, "archive/") && r.Method == "GET":
771+ s.download(w, r, repo, strings.TrimPrefix(rest, "archive/"))
772+ case rest == "feed" && r.Method == "GET":
773+ s.feed(w, r, repo)
774+ case rest == "issues" && r.Method == "GET":
775+ s.issues(w, r, repo)
776+ case rest == "issues/new" && r.Method == "GET":
777+ s.newIssue(w, r, repo)
778+ case rest == "issues/new" && r.Method == "POST":
779+ s.createIssue(w, r, repo)
780+ case strings.HasPrefix(rest, "issues/"):
781+ s.issue(w, r, repo, strings.TrimPrefix(rest, "issues/"))
737782 case rest == "refs" && r.Method == "GET":
738783 s.refs(w, r, repo)
739784 case strings.HasPrefix(rest, "tree/") && r.Method == "GET":
@@ -784,22 +829,28 @@ func (s *Server) publicKeys(w http.ResponseWriter, r *http.Request, spec string)
784829 }
785830
786831 type repoPage struct {
787- Repo Repo
788- Ref string
789- Commit string
790- Owner bool
791- Empty bool
792- Tab string
793- Open int
794- Parent *Repo
795- User *User
796- IsOwner bool
832+ Repo Repo
833+ Ref string
834+ Commit string
835+ Owner bool
836+ Empty bool
837+ Tab string
838+ Open int
839+ OpenIssues int
840+ Parent *Repo
841+ User *User
842+ IsOwner bool
843+ Stars int
844+ Starred bool
797845 }
798846
799847 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
800848 g := Git{Dir: s.repoDir(repo)}
801849 user := s.current(r)
802- rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user}
850+ rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: s.store.StarCount(repo.ID)}
851+ if user != nil {
852+ rp.Starred = s.store.Starred(user.ID, repo.ID)
853+ }
803854 if repo.Parent != 0 {
804855 if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private {
805856 rp.Parent = &parent
@@ -841,7 +892,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
841892 if name != "" {
842893 data["ReadmeName"] = name
843894 if strings.HasSuffix(strings.ToLower(name), ".md") {
844- data["Readme"] = s.renderMarkdown(content)
895+ data["Readme"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/")
845896 } else {
846897 data["ReadmeText"] = string(content)
847898 }
@@ -857,12 +908,34 @@ const maxRender = 4 << 20
857908 const maxHighlight = 512 << 10
858909
859910 func (s *Server) renderMarkdown(src []byte) template.HTML {
911+ return s.renderMarkdownAt(src, "", "")
912+}
913+
914+var relLink = regexp.MustCompile(`(src|href)="([^"]+)"`)
915+
916+func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML {
860917 src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$"))
861918 var buf bytes.Buffer
862919 if err := s.markdown.Convert(src, &buf); err != nil {
863920 return template.HTML("<pre>" + template.HTMLEscapeString(string(src)) + "</pre>")
864921 }
865- return template.HTML(s.policy.SanitizeBytes(buf.Bytes()))
922+ out := s.policy.SanitizeBytes(buf.Bytes())
923+ if rawBase != "" {
924+ out = relLink.ReplaceAllFunc(out, func(m []byte) []byte {
925+ parts := relLink.FindSubmatch(m)
926+ attr, target := string(parts[1]), string(parts[2])
927+ if strings.Contains(target, "://") || strings.HasPrefix(target, "/") || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") {
928+ return m
929+ }
930+ target = strings.TrimPrefix(target, "./")
931+ base := treeBase
932+ if attr == "src" {
933+ base = rawBase
934+ }
935+ return []byte(attr + `="` + base + target + `"`)
936+ })
937+ }
938+ return template.HTML(out)
866939 }
867940
868941 func hasMath(h any) bool {
@@ -944,7 +1017,11 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st
9441017 } else if isBinary(content) {
9451018 data["Binary"] = true
9461019 } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" {
947- data["Markdown"] = s.renderMarkdown(content)
1020+ dir := ""
1021+ if i := strings.LastIndex(filePath, "/"); i >= 0 {
1022+ dir = filePath[:i+1]
1023+ }
1024+ data["Markdown"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/"+dir, "/"+repo.Path()+"/tree/"+rp.Ref+"/"+dir)
9481025 } else if h, ok := highlightLimited(filePath, content); ok {
9491026 data["Highlighted"] = h
9501027 } else {